Repository: govuk-one-login/mobile-id-check-async
Pull request #586: DCMAW-12243: Issuing VC | Get Biometric Session from ReadID
Author: login: tmulberry-gds
Created: 2025-04-15T12:54:01Z | Merged: 2025-04-24T10:26:17Z

## Jira Ticket
<!-- Add your Jira ticket link here. -->
DCMAW-12243

## Description of changes
<!-- 
- Describe what changed and why
- Call out specific areas that need focus in the review
- Call out new patterns and articulate why they're being proposed
- For larger PRs, provide guidance on how best to start the review/understand the change
-->

## Review guidance
<details>
<summary>Review checklist</summary>

### Functional Review
- **Functionality**: Does it meet the acceptance criteria on the ticket and work as expected?
- **Requirements**: Does the code meet functional and non-functional requirements including compliance with programme standards and security gates?

### Security & Compliance
- **Personally Identifiable Information**: Is it possible for PII to be logged?
- **Security Considerations**: Are there any security implications that need to be addressed?

### Quality Assurance
- **Testing**: Is the code well-tested with sufficient coverage to provide confidence in correctness?
- **Edge Cases**: Have edge cases been considered and handled appropriately?

### Code Quality
- **Readability**: Is the code easy to understand for all team members, with clear naming and appropriate documentation?
- **Maintainability**: Is the code easy to change, reuse, and extend?
- **Code Style**: Does it follow our coding conventions and best practices?
- **Code Quality**: Is the code maintainable and following best practices? See [Values, Principles & Practices](https://govukverify.atlassian.net/wiki/spaces/DCMAW/pages/4955865126/ID+Check+Web+-+Values+Principles+and+Practices)

### Observability & Operations
- **Observability**: Are there appropriate logs/metrics that would help debug and monitor the service?
- **Performance**: Are there any performance considerations or potential bottlenecks?

### Documentation
- **Documentation**: Is the code well documented? Is there any existing documentation that needs updating?
- **Comments**: Are complex sections of code adequately commented if the intent is not clear?

### Review PR:
- **Title**: Contains ticket number and clear summary of change
- **Description**: Has clear description of change
</details>

## Evidence
<!-- 
- Provide evidence that changes work as expected
- For UI changes, include screenshots or recordings
- For API changes, include example requests/responses
- For infrastructure changes, include relevant logs or screenshots

Note: Leave this blank if no testing additional to the pull request workflows has been executed

When you should run the test container:
- If you've touched Docker configuration
- If you've added new environment variables
- For UI changes
-->

## Documentation
<!-- 
- Call out if key repository documentation has been updated
- Link to external documentation that has been updated

Note: Leave this blank if no documentation changes have been made
-->


Diff:
diff --git a/backend-api/infra/functions/issueBiometricCredential.yaml b/backend-api/infra/functions/issueBiometricCredential.yaml
index f94085c17..d46bee401 100644
--- a/backend-api/infra/functions/issueBiometricCredential.yaml
+++ b/backend-api/infra/functions/issueBiometricCredential.yaml
@@ -28,6 +28,10 @@ Resources:
         Variables:
           BIOMETRIC_VIEWER_KEY_SECRET_PATH: !FindInMap [EnvironmentVariables, !Ref Environment, BiometricViewerAccessKey]
           BIOMETRIC_VIEWER_ACCESS_KEY_SECRET_CACHE_DURATION_IN_SECONDS: !FindInMap [EnvironmentVariables, !Ref Environment, BiometricViewerAccessKeySecretCacheDurationInSeconds]
+          READID_BASE_URL: !If
+            - UseDevOverrideReadIdBaseUrl
+            - !Ref DevOverrideReadIdBaseUrl
+            - !FindInMap [EnvironmentVariables, !Ref Environment, ReadIdBaseUrl]
       Role: !GetAtt AsyncIssueBiometricCredentialLambdaRole.Arn
       VpcConfig:
         SubnetIds:
@@ -101,7 +105,21 @@ Resources:
                   - sqs:DeleteMessage
                   - sqs:GetQueueAttributes
                   - sqs:ChangeMessageVisibility
-                Resource: !GetAtt VendorProcessingSQS.Arn
+                Resource: 
+                  - !GetAtt VendorProcessingSQS.Arn
+        - PolicyName: AsyncIssueBiometricCredentialFunctionIPVCoreSQSPolicy
+          PolicyDocument:
+            Version: "2012-10-17"
+            Statement:
+              - Effect: Allow
+                Action:
+                  - sqs:SendMessage
+                Resource: !GetAtt IPVCoreSQS.Arn
+              - Effect: Allow
+                Action:
+                  - kms:Decrypt
+                  - kms:GenerateDataKey
+                Resource: !GetAtt IPVCoreKMSEncryptionKey.Arn
         - PolicyName: KMSPolicy
           PolicyDocument:
             Version: "2012-10-17"
diff --git a/backend-api/src/functions/adapters/aws/sqs/types.ts b/backend-api/src/functions/adapters/aws/sqs/types.ts
index 145281e47..b53141528 100644
--- a/backend-api/src/functions/adapters/aws/sqs/types.ts
+++ b/backend-api/src/functions/adapters/aws/sqs/types.ts
@@ -3,11 +3,13 @@ export interface VendorProcessingMessage {
   sessionId: string;
 }
 
-export interface AbortSessionMessage {
+export interface OutboundQueueErrorMessage {
   sub: string;
   state: string;
   error: string;
   error_description: string;
 }
 
-export type SQSMessageBody = VendorProcessingMessage | AbortSessionMessage;
+export type SQSMessageBody =
+  | VendorProcessingMessage
+  | OutboundQueueErrorMessage;
diff --git a/backend-api/src/functions/asyncAbortSession/handlerDependencies.ts b/backend-api/src/functions/asyncAbortSession/handlerDependencies.ts
index 3c0a8c20e..16fbb6ce5 100644
--- a/backend-api/src/functions/asyncAbortSession/handlerDependencies.ts
+++ b/backend-api/src/functions/asyncAbortSession/handlerDependencies.ts
@@ -1,6 +1,6 @@
 import { DynamoDbAdapter } from "../adapters/aws/dynamo/dynamoDbAdapter";
 import { sendMessageToSqs } from "../adapters/aws/sqs/sendMessageToSqs";
-import { AbortSessionMessage } from "../adapters/aws/sqs/types";
+import { OutboundQueueErrorMessage } from "../adapters/aws/sqs/types";
 import { SessionRegistry } from "../common/session/SessionRegistry/SessionRegistry";
 import { EventService } from "../services/events/eventService";
 import { IEventService } from "../services/events/types";
@@ -12,7 +12,7 @@ export type IAsyncAbortSessionDependencies = {
   getEventService: (sqsQueue: string) => IEventService;
   sendMessageToSqs: (
     sqsArn: string,
-    messageBody: AbortSessionMessage,
+    messageBody: OutboundQueueErrorMessage,
   ) => Promise<Result<void, void>>;
 };
 
diff --git a/backend-api/src/functions/asyncIssueBiometricCredential/RetainMessageOnQueue.ts b/backend-api/src/functions/asyncIssueBiometricCredential/RetainMessageOnQueue.ts
index 3776dc6fd..9227997b6 100644
--- a/backend-api/src/functions/asyncIssueBiometricCredential/RetainMessageOnQueue.ts
+++ b/backend-api/src/functions/asyncIssueBiometricCredential/RetainMessageOnQueue.ts
@@ -1,7 +1,9 @@
 type RetainMessageOnQueueMessages =
   | "Invalid config"
   | "Failed to retrieve biometric viewer key"
-  | "Unexpected failure retrieving session from database";
+  | "Unexpected failure retrieving session from database"
+  | `Retryable error retrieving biometric session`
+  | `Biometric session not ready: ${string}`;
 
 export class RetainMessageOnQueue extends Error {
   constructor(message: RetainMessageOnQueueMessages) {
diff --git a/backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.test.ts b/backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.test.ts
index 3e13e7e28..5af8cd50d 100644
--- a/backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.test.ts
+++ b/backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.test.ts
@@ -7,17 +7,23 @@ import { lambdaHandlerConstructor } from "./asyncIssueBiometricCredentialHandler
 import { RetainMessageOnQueue } from "./RetainMessageOnQueue";
 import { IssueBiometricCredentialDependencies } from "./handlerDependencies";
 import {
-  mockBiometricSessionId,
-  mockInertEventService,
-  mockInertSessionRegistry,
   mockSessionId,
   mockSuccessfulEventService,
+  mockWriteGenericEventSuccessResult,
+  mockSuccessfulSendMessageToSqs,
+  mockInertSessionRegistry,
+  mockBiometricSessionId,
+  mockInertEventService,
   validBiometricSessionFinishedAttributes,
   validResultSentAttributes,
 } from "../testUtils/unitTestData";
 import { SessionRegistry } from "../common/session/SessionRegistry/SessionRegistry";
 import { emptyFailure, errorResult, successResult } from "../utils/result";
 import { GetSessionError } from "../common/session/SessionRegistry/types";
+import {
+  BiometricSession,
+  GetBiometricSessionError,
+} from "./getBiometricSession/getBiometricSession";
 
 describe("Async Issue Biometric Credential", () => {
   let dependencies: IssueBiometricCredentialDependencies;
@@ -26,6 +32,38 @@ describe("Async Issue Biometric Credential", () => {
   let consoleErrorSpy: jest.SpyInstance;
   let lambdaError: unknown;
 
+  const mockReadyBiometricSession: BiometricSession = {
+    finish: "DONE",
+  };
+
+  const mockNotReadyBiometricSession: BiometricSession = {
+    finish: "PROCESSING",
+  };
+
+  const mockGetBiometricSessionSuccess = jest
+    .fn()
+    .mockResolvedValue(successResult(mockReadyBiometricSession));
+
+  const mockGetBiometricSessionNotReady = jest
+    .fn()
+    .mockResolvedValue(successResult(mockNotReadyBiometricSession));
+
+  const mockRetryableError: GetBiometricSessionError = {
+    isRetryable: true,
+  };
+
+  const mockNonRetryableError: GetBiometricSessionError = {
+    isRetryable: false,
+  };
+
+  const mockGetBiometricSessionRetryableFailure = jest
+    .fn()
+    .mockResolvedValue(errorResult(mockRetryableError));
+
+  const mockGetBiometricSessionNonRetryableFailure = jest
+    .fn()
+    .mockResolvedValue(errorResult(mockNonRetryableError));
+
   const mockGetSecretsSuccess = jest.fn().mockResolvedValue(
     successResult({
       mockBiometricViewerAccessKey: "mockViewerKey",
@@ -51,7 +89,6 @@ describe("Async Issue Biometric Credential", () => {
     eventSourceARN: "mockEventSourceArn",
     awsRegion: "mockAwsRegion",
   };
-
   const validSqsEvent: SQSEvent = {
     Records: [validVendorProcessingQueueSqsEventRecord],
   };
@@ -70,13 +107,17 @@ describe("Async Issue Biometric Credential", () => {
       env: {
         BIOMETRIC_VIEWER_KEY_SECRET_PATH: "mockBiometricViewerAccessKey",
         BIOMETRIC_VIEWER_ACCESS_KEY_SECRET_CACHE_DURATION_IN_SECONDS: "900",
+        READID_BASE_URL: "mockReadIdBaseUrl",
+        IPVCORE_OUTBOUND_SQS: "mockIpvcoreOutboundSqs",
         SESSION_TABLE_NAME: "mockTableName",
         TXMA_SQS: "mockTxmaSqs",
         ISSUER: "mockIssuer",
       },
       getSessionRegistry: () => mockSessionRegistrySuccess,
       getSecrets: mockGetSecretsSuccess,
+      getBiometricSession: mockGetBiometricSessionSuccess,
       getEventService: () => mockSuccessfulEventService,
+      sendMessageToSqs: mockSuccessfulSendMessageToSqs,
     };
     context = buildLambdaContext();
     consoleInfoSpy = jest.spyOn(console, "info");
@@ -110,6 +151,8 @@ describe("Async Issue Biometric Credential", () => {
     describe.each([
       ["BIOMETRIC_VIEWER_KEY_SECRET_PATH"],
       ["BIOMETRIC_VIEWER_ACCESS_KEY_SECRET_CACHE_DURATION_IN_SECONDS"],
+      ["READID_BASE_URL"],
+      ["IPVCORE_OUTBOUND_SQS"],
       ["SESSION_TABLE_NAME"],
       ["TXMA_SQS"],
       ["ISSUER"],
@@ -353,60 +396,247 @@ describe("Async Issue Biometric Credential", () => {
     });
   });
 
-  describe("When there is an error getting secrets", () => {
+  describe("When session state is ASYNC_RESULT_SENT", () => {
     beforeEach(async () => {
-      dependencies.getSecrets = jest.fn().mockResolvedValue(emptyFailure());
-      try {
-        await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
-      } catch (error: unknown) {
-        lambdaError = error;
-      }
+      dependencies.getSessionRegistry = () => ({
+        ...mockInertSessionRegistry,
+        getSession: jest
+          .fn()
+          .mockResolvedValue(successResult(validResultSentAttributes)),
+      });
+      await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
     });
 
-    it("Throws RetainMessageOnQueue", async () => {
-      expect(lambdaError).toStrictEqual(
-        new RetainMessageOnQueue("Failed to retrieve biometric viewer key"),
-      );
+    it("Does not make a call to get secrets", () => {
+      expect(mockGetSecretsSuccess).not.toHaveBeenCalled();
+    });
+
+    it("Logs COMPLETED with sessionId", async () => {
+      expect(consoleInfoSpy).toHaveBeenCalledWithLogFields({
+        messageCode: "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_COMPLETED",
+        sessionId: mockSessionId,
+      });
     });
   });
 
-  describe("Given the lambda handler reads a valid SQSEvent", () => {
-    describe("Given sessionState is ASYNC_RESULT_SENT", () => {
+  describe("When session state is ASYNC_BIOMETRIC_SESSION_FINISHED", () => {
+    beforeEach(async () => {
+      // Uses the default mockSessionRegistrySuccess which returns validBiometricSessionFinishedAttributes
+      await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
+    });
+
+    it("Passes correct arguments to get secrets", async () => {
+      expect(mockGetSecretsSuccess).toHaveBeenCalledWith({
+        secretNames: ["mockBiometricViewerAccessKey"],
+        cacheDurationInSeconds: 900,
+      });
+    });
+
+    it("Passes correct arguments to get biometric session", async () => {
+      expect(mockGetBiometricSessionSuccess).toHaveBeenCalledWith(
+        "mockReadIdBaseUrl",
+        mockBiometricSessionId,
+        "mockViewerKey",
+      );
+    });
+
+    describe("When there is an error getting secrets", () => {
       beforeEach(async () => {
-        dependencies.getSessionRegistry = () => ({
-          ...mockInertSessionRegistry,
-          getSession: jest
-            .fn()
-            .mockResolvedValue(successResult(validResultSentAttributes)),
-        });
-        await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
+        dependencies.getSecrets = jest.fn().mockResolvedValue(emptyFailure());
+        try {
+          await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
+        } catch (error: unknown) {
+          lambdaError = error;
+        }
       });
 
-      it("Does not make a call to get secrets", () => {
-        expect(mockGetSecretsSuccess).not.toHaveBeenCalled();
+      it("Throws RetainMessageOnQueue", async () => {
+        expect(lambdaError).toStrictEqual(
+          new RetainMessageOnQueue("Failed to retrieve biometric viewer key"),
+        );
       });
+    });
 
-      it("Logs COMPLETED with sessionId", async () => {
-        expect(consoleInfoSpy).toHaveBeenCalledWithLogFields({
-          messageCode: "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_COMPLETED",
-          sessionId: mockSessionId,
+    describe("When biometric session retrieval fails", () => {
+      describe("With retryable error", () => {
+        beforeEach(async () => {
+          dependencies.getBiometricSession =
+            mockGetBiometricSessionRetryableFailure;
+          try {
+            await lambdaHandlerConstructor(
+              dependencies,
+              validSqsEvent,
+              context,
+            );
+          } catch (error: unknown) {
+            lambdaError = error;
+          }
+        });
+
+        it("Throws RetainMessageOnQueue with appropriate message", () => {
+          expect(lambdaError).toBeInstanceOf(RetainMessageOnQueue);
+          expect((lambdaError as RetainMessageOnQueue).message).toMatch(
+            /Retryable error/,
+          );
+        });
+      });
+
+      describe("With non-retryable error", () => {
+        beforeEach(async () => {
+          dependencies.getBiometricSession =
+            mockGetBiometricSessionNonRetryableFailure;
+          await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
+        });
+
+        it("Sends error to IPV Core", () => {
+          expect(mockSuccessfulSendMessageToSqs).toHaveBeenCalledWith(
+            "mockIpvcoreOutboundSqs",
+            {
+              sub: "mockSubjectIdentifier",
+              state: "mockClientState",
+              error: "server_error",
+              error_description: "Internal server error",
+            },
+          );
+        });
+
+        it("Sends event to TxMA", () => {
+          expect(mockWriteGenericEventSuccessResult).toHaveBeenCalledWith(
+            expect.objectContaining({
+              eventName: "DCMAW_ASYNC_CRI_5XXERROR",
+              componentId: "mockIssuer",
+            }),
+          );
+        });
+
+        describe("When sending error to IPV Core fails", () => {
+          beforeEach(async () => {
+            dependencies.sendMessageToSqs = jest
+              .fn()
+              .mockResolvedValue(emptyFailure());
+            dependencies.getBiometricSession =
+              mockGetBiometricSessionNonRetryableFailure;
+            await lambdaHandlerConstructor(
+              dependencies,
+              validSqsEvent,
+              context,
+            );
+          });
+
+          it("Logs IPV Core message error", () => {
+            expect(consoleErrorSpy).toHaveBeenCalledWithLogFields({
+              messageCode:
+                "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_IPV_CORE_MESSAGE_ERROR",
+            });
+          });
+
+          it("Still sends event to TxMA", () => {
+            expect(mockWriteGenericEventSuccessResult).toHaveBeenCalledWith(
+              expect.objectContaining({
+                eventName: "DCMAW_ASYNC_CRI_5XXERROR",
+                componentId: "mockIssuer",
+              }),
+            );
+          });
+
+          describe("Given writing TxMA event fails", () => {
+            beforeEach(async () => {
+              dependencies.getEventService = () => ({
+                ...mockInertEventService,
+                writeGenericEvent: jest.fn().mockResolvedValue(
+                  errorResult({
+                    errorMessage: "mockError",
+                  }),
+                ),
+              });
+
+              await lambdaHandlerConstructor(
+                dependencies,
+                validSqsEvent,
+                context,
+              );
+            });
+
+            it("Logs DCMAW_ASYNC_CRI_5XXERROR audit event error", () => {
+              expect(consoleErrorSpy).toHaveBeenCalledWithLogFields({
+                messageCode: "MOBILE_ASYNC_ERROR_WRITING_AUDIT_EVENT",
+              });
+            });
+
+            it("Does not log COMPLETED", () => {
+              expect(consoleInfoSpy).not.toHaveBeenCalledWithLogFields({
+                messageCode:
+                  "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_COMPLETED",
+                data: { auditEventName: "DCMAW_ASYNC_CRI_5XXERROR" },
+              });
+            });
+          });
+
+          describe("Given writing TxMA event succeeds", () => {
+            beforeEach(async () => {
+              await lambdaHandlerConstructor(
+                dependencies,
+                validSqsEvent,
+                context,
+              );
+            });
+
+            it("Writes DCMAW_ASYNC_CRI_5XXERROR to TxMA", () => {
+              expect(
+                mockSuccessfulEventService.writeGenericEvent,
+              ).toBeCalledWith({
+                eventName: "DCMAW_ASYNC_CRI_5XXERROR",
+                componentId: "mockIssuer",
+                getNowInMilliseconds: Date.now,
+                sessionId: mockSessionId,
+                govukSigninJourneyId: "mockGovukSigninJourneyId",
+                ipAddress: undefined,
+                redirect_uri: undefined,
+                sub: "mockSubjectIdentifier",
+                suspected_fraud_signal: undefined,
+                txmaAuditEncoded: undefined,
+              });
+            });
+
+            it("Logs COMPLETED with sessionId", () => {
+              expect(consoleInfoSpy).toHaveBeenCalledWithLogFields({
+                messageCode:
+                  "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_COMPLETED",
+                sessionId: mockSessionId,
+              });
+            });
+          });
         });
       });
     });
 
-    describe("Given sessionState is ASYNC_BIOMETRIC_SESSION_FINISHED", () => {
+    describe("When biometric session is not ready", () => {
       beforeEach(async () => {
-        await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
+        dependencies.getBiometricSession = mockGetBiometricSessionNotReady;
+        try {
+          await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
+        } catch (error: unknown) {
+          lambdaError = error;
+        }
       });
 
-      it("Passes correct arguments to get secrets", () => {
-        expect(mockGetSecretsSuccess).toHaveBeenCalledWith({
-          secretNames: ["mockBiometricViewerAccessKey"],
-          cacheDurationInSeconds: 900,
+      it("Logs the appropriate message", () => {
+        expect(consoleInfoSpy).toHaveBeenCalledWithLogFields({
+          messageCode: "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_NOT_READY",
+          data: {
+            finish: "PROCESSING",
+          },
         });
       });
 
-      it("Logs COMPLETED with sessionId", async () => {
+      it("Throws RetainMessageOnQueue with appropriate message", () => {
+        expect(lambdaError).toBeInstanceOf(RetainMessageOnQueue);
+        expect((lambdaError as RetainMessageOnQueue).message).toMatch(
+          /Biometric session not ready: PROCESSING/,
+        );
+      });
+
+      it("Logs COMPLETED with sessionId", () => {
         expect(consoleInfoSpy).toHaveBeenCalledWithLogFields({
           messageCode: "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_COMPLETED",
           sessionId: mockSessionId,
diff --git a/backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.ts b/backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.ts
index 957037976..a0b61edbf 100644
--- a/backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.ts
+++ b/backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.ts
@@ -7,17 +7,28 @@ import { logger } from "../common/logging/logger";
 import { LogMessage } from "../common/logging/LogMessage";
 import { setupLogger } from "../common/logging/setupLogger";
 import { validateVendorProcessingQueueSqsEvent } from "./validateSqsEvent";
-import { getIssueBiometricCredentialConfig } from "./issueBiometricCredentialConfig";
-import { GetSessionIssueBiometricCredential } from "../common/session/getOperations/IssueBiometricCredential/GetSessionIssueBiometricCredential";
+import {
+  getIssueBiometricCredentialConfig,
+  IssueBiometricCredentialConfig,
+} from "./issueBiometricCredentialConfig";
 import {
   GetSessionError,
   GetSessionFailed,
 } from "../common/session/SessionRegistry/types";
 import { Result, emptyFailure, successResult } from "../utils/result";
 import { GetSecrets } from "../common/config/secrets";
+
+import { OutboundQueueErrorMessage } from "../adapters/aws/sqs/types";
+
 import { IEventService } from "../services/events/types";
 import { RetainMessageOnQueue } from "./RetainMessageOnQueue";
-import { SessionState } from "../common/session/session";
+import {
+  BiometricSessionFinishedAttributes,
+  SessionAttributes,
+  SessionState,
+} from "../common/session/session";
+import { GetBiometricSessionError } from "./getBiometricSession/getBiometricSession";
+import { GetSessionIssueBiometricCredential } from "../common/session/getOperations/IssueBiometricCredential/GetSessionIssueBiometricCredential";
 
 export async function lambdaHandlerConstructor(
   dependencies: IssueBiometricCredentialDependencies,
@@ -38,19 +49,21 @@ export async function lambdaHandlerConstructor(
   if (validateSqsEventResult.isError) {
     return;
   }
-  const sessionId = validateSqsEventResult.value;
 
+  const sessionId = validateSqsEventResult.value;
   logger.appendKeys({ sessionId });
 
   const sessionRegistry = dependencies.getSessionRegistry(
     config.SESSION_TABLE_NAME,
   );
+
   const eventService = dependencies.getEventService(config.TXMA_SQS);
 
   const getSessionResult = await sessionRegistry.getSession(
     sessionId,
     new GetSessionIssueBiometricCredential(),
   );
+
   if (getSessionResult.isError) {
     return handleGetSessionError({
       errorData: getSessionResult.value,
@@ -75,6 +88,81 @@ export async function lambdaHandlerConstructor(
     throw new RetainMessageOnQueue("Failed to retrieve biometric viewer key");
   }
 
+  const viewerKey = viewerKeyResult.value;
+  const { biometricSessionId } =
+    sessionAttributes as BiometricSessionFinishedAttributes;
+  logger.appendKeys({ biometricSessionId });
+
+  const biometricSessionResult = await dependencies.getBiometricSession(
+    config.READID_BASE_URL,
+    biometricSessionId,
+    viewerKey,
+  );
+
+  if (biometricSessionResult.isError) {
+    const eventService = dependencies.getEventService(config.TXMA_SQS);
+    const error: GetBiometricSessionError = biometricSessionResult.value;
+
+    // Check if the error was retryable based on error info
+    if (error.isRetryable) {
+      throw new RetainMessageOnQueue(
+        `Retryable error retrieving biometric session`,
+      );
+    }
+
+    const handleSendErrorMessageToOutboundQueueResponse =
+      await handleSendErrorMessageToOutboundQueue(
+        dependencies,
+        sessionAttributes,
+        config,
+        { error: "server_error", error_description: "Internal server error" },
+      );
+    if (handleSendErrorMessageToOutboundQueueResponse.isError) {
+      logger.error(
+        LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_IPV_CORE_MESSAGE_ERROR,
+      );
+    }
+
+    const writeEventResult = await eventService.writeGenericEvent({
+      eventName: "DCMAW_ASYNC_CRI_5XXERROR",
+      sub: sessionAttributes?.subjectIdentifier,
+      sessionId,
+      govukSigninJourneyId: sessionAttributes?.govukSigninJourneyId,
+      getNowInMilliseconds: Date.now,
+      componentId: config.ISSUER,
+      ipAddress: undefined,
+      txmaAuditEncoded: undefined,
+      redirect_uri: sessionAttributes?.redirectUri,
+      suspected_fraud_signal: undefined,
+    });
+
+    if (writeEventResult.isError) {
+      logger.error(LogMessage.ERROR_WRITING_AUDIT_EVENT, {
+        data: {
+          auditEventName: "DCMAW_ASYNC_CRI_5XXERROR",
+        },
+      });
+    }
+
+    return;
+  }
+
+  const biometricSession = biometricSessionResult.value;
+
+  if (biometricSession.finish !== "DONE") {
+    logger.info(
+      LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_BIOMETRIC_SESSION_NOT_READY,
+      {
+        data: { finish: biometricSession.finish },
+      },
+    );
+    logger.info(LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_COMPLETED);
+    throw new RetainMessageOnQueue(
+      `Biometric session not ready: ${biometricSession.finish}`,
+    );
+  }
+
+  // Biometric session is ready, continue processing
   logger.info(LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_COMPLETED);
 }
 
@@ -133,6 +221,26 @@ const handleGetSessionError = async (
   }
 };
 
+const handleSendErrorMessageToOutboundQueue = async (
+  dependencies: IssueBiometricCredentialDependencies,
+  sessionAttributes: SessionAttributes,
+  config: IssueBiometricCredentialConfig,
+  error: { error: string; error_description: string },
+): Promise<Result<void, void>> => {
+  const ipvCoreOutboundMessage: OutboundQueueErrorMessage = {
+    sub: sessionAttributes.subjectIdentifier,
+    state: sessionAttributes.clientState,
+    ...error,
+  };
+
+  const sendMessageToIPVCoreOutboundQueueResult =
+    await dependencies.sendMessageToSqs(
+      config.IPVCORE_OUTBOUND_SQS,
+      ipvCoreOutboundMessage,
+    );
+  return sendMessageToIPVCoreOutboundQueueResult;
+};
+
 interface HandleGetSessionErrorParameters {
   errorData: GetSessionFailed;
   eventService: IEventService;
diff --git a/backend-api/src/functions/asyncIssueBiometricCredential/getBiometricSession/getBiometricSession.test.ts b/backend-api/src/functions/asyncIssueBiometricCredential/getBiometricSession/getBiometricSession.test.ts
new file mode 100644
index 000000000..8e06bd765
--- /dev/null
+++ b/backend-api/src/functions/asyncIssueBiometricCredential/getBiometricSession/getBiometricSession.test.ts
@@ -0,0 +1,326 @@
+import { errorResult, Result, successResult } from "../../utils/result";
+import {
+  getBiometricSession,
+  BiometricSession,
+  GetBiometricSessionError,
+} from "./getBiometricSession";
+import { expect } from "@jest/globals";
+import "../../../../tests/testUtils/matchers";
+import { ISendHttpRequest } from "../../adapters/http/sendHttpRequest";
+
+describe("getBiometricSession", () => {
+  let result: Result<BiometricSession, GetBiometricSessionError>;
+  let consoleDebugSpy: jest.SpyInstance;
+  let consoleErrorSpy: jest.SpyInstance;
+  let mockSendHttpRequest: ISendHttpRequest;
+  const mockBiometricSessionId = "mockBiometricSessionId";
+
+  const expectedHttpRequest = {
+    url: "https://mockUrl.com/odata/v1/ODataServlet/Sessions('mockBiometricSessionId')",
+    method: "GET",
+    headers: {
+      "X-Innovalor-Authorization": "mockSubmitterKey",
+      Accept: "application/json;odata.metadata=minimal",
+      "Content-Type": "application/json;odata.metadata=minimal",
+      "OData-MaxVersion": "4.0",
+      "OData-Version": "4.0",
+    },
+  };
+
+  const expectedRetryConfig = {
+    retryableStatusCodes: [
+      429, 500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511,
+    ],
+    delayInMillis: 50,
+  };
+
+  beforeEach(() => {
+    consoleDebugSpy = jest.spyOn(console, "debug");
+    consoleErrorSpy = jest.spyOn(console, "error");
+
+    jest.resetModules();
+  });
+
+  describe("On every call", () => {
+    beforeEach(async () => {
+      mockSendHttpRequest = jest.fn().mockResolvedValue(
+        successResult({
+          statusCode: 200,
+          body: JSON.stringify({
+            finish: "DONE",
+          }),
+          headers: {
+            mockHeaderKey: "mockHeaderValue",
+          },
+        }),
+      );
+
+      result = await getBiometricSession(
+        "https://mockUrl.com",
+        mockBiometricSessionId,
+        "mockSubmitterKey",
+        mockSendHttpRequest,
+      );
+    });
+
+    it("Logs network call attempt at debug level", () => {
+      expect(consoleDebugSpy).toHaveBeenCalledWithLogFields({
+        messageCode:
+          "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_ATTEMPT",
+      });
+    });
+  });
+
+  describe("Given an error is returned when requesting session", () => {
+    describe("Given a retryable error (5XX)", () => {
+      beforeEach(async () => {
+        mockSendHttpRequest = jest.fn().mockResolvedValue(
+          errorResult({
+            statusCode: 503,
+            message: "Service Unavailable",
+          }),
+        );
+        result = await getBiometricSession(
+          "https://mockUrl.com",
+          mockBiometricSessionId,
+          "mockSubmitterKey",
+          mockSendHttpRequest,
+        );
+      });
+
+      it("Logs error", () => {
+        expect(consoleErrorSpy).toHaveBeenCalledWithLogFields({
+          messageCode:
+            "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE",
+        });
+      });
+
+      it("Returns an error result with retryable flag", () => {
+        expect(result.isError).toBe(true);
+        expect(result.value).toEqual({
+          isRetryable: true,
+        });
+        expect(mockSendHttpRequest).toBeCalledWith(
+          expectedHttpRequest,
+          expectedRetryConfig,
+        );
+      });
+    });
+
+    describe("Given a non-retryable error (4XX)", () => {
+      beforeEach(async () => {
+        mockSendHttpRequest = jest.fn().mockResolvedValue(
+          errorResult({
+            statusCode: 404,
+            message: "Not Found",
+          }),
+        );
+        result = await getBiometricSession(
+          "https://mockUrl.com",
+          mockBiometricSessionId,
+          "mockSubmitterKey",
+          mockSendHttpRequest,
+        );
+      });
+
+      it("Logs error", () => {
+        expect(consoleErrorSpy).toHaveBeenCalledWithLogFields({
+          messageCode:
+            "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE",
+        });
+      });
+
+      it("Returns an error result with non-retryable flag", () => {
+        expect(result.isError).toBe(true);
+        expect(result.value).toEqual({
+          isRetryable: false,
+        });
+        expect(mockSendHttpRequest).toBeCalledWith(
+          expectedHttpRequest,
+          expectedRetryConfig,
+        );
+      });
+    });
+
+    describe("Given a network error (no status code)", () => {
+      beforeEach(async () => {
+        mockSendHttpRequest = jest.fn().mockResolvedValue(
+          errorResult({
+            message: "Network Error",
+          }),
+        );
+        result = await getBiometricSession(
+          "https://mockUrl.com",
+          mockBiometricSessionId,
+          "mockSubmitterKey",
+          mockSendHttpRequest,
+        );
+      });
+
+      it("Treats it as retryable", () => {
+        expect(result.isError).toBe(true);
+        expect(result.value).toEqual({
+          isRetryable: true,
+        });
+      });
+    });
+  });
+
+  describe("Given the response is invalid", () => {
+    describe("Given response body is undefined", () => {
+      beforeEach(async () => {
+        mockSendHttpRequest = jest.fn().mockResolvedValue(
+          successResult({
+            statusCode: 200,
+            body: undefined,
+            headers: {
+              mockHeaderKey: "mockHeaderValue",
+            },
+          }),
+        );
+
+        result = await getBiometricSession(
+          "https://mockUrl.com",
+          mockBiometricSessionId,
+          "mockSubmitterKey",
+          mockSendHttpRequest,
+        );
+      });
+
+      it("Logs error", () => {
+        expect(consoleErrorSpy).toHaveBeenCalledWithLogFields({
+          messageCode:
+            "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE",
+        });
+      });
+
+      it("Returns an error result with retryable flag set to false", () => {
+        expect(result.isError).toBe(true);
+        expect(result.value).toEqual({
+          isRetryable: false,
+        });
+        expect(mockSendHttpRequest).toBeCalledWith(
+          expectedHttpRequest,
+          expectedRetryConfig,
+        );
+      });
+    });
+
+    describe("Given response body cannot be parsed", () => {
+      beforeEach(async () => {
+        mockSendHttpRequest = jest.fn().mockResolvedValue(
+          successResult({
+            statusCode: 200,
+            body: "Invalid JSON",
+            headers: {
+              mockHeaderKey: "mockHeaderValue",
+            },
+          }),
+        );
+
+        result = await getBiometricSession(
+          "https://mockUrl.com",
+          mockBiometricSessionId,
+          "mockSubmitterKey",
+          mockSendHttpRequest,
+        );
+      });
+
+      it("Logs error", () => {
+        expect(consoleErrorSpy).toHaveBeenCalledWithLogFields({
+          messageCode:
+            "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE",
+        });
+      });
+
+      it("Returns an error result with retryable flag set to false", () => {
+        expect(result.isError).toBe(true);
+        expect(result.value).toEqual({
+          isRetryable: false,
+        });
+        expect(mockSendHttpRequest).toBeCalledWith(
+          expectedHttpRequest,
+          expectedRetryConfig,
+        );
+      });
+    });
+
+    describe("Given response has invalid structure", () => {
+      beforeEach(async () => {
+        mockSendHttpRequest = jest.fn().mockResolvedValue(
+          successResult({
+            statusCode: 200,
+            body: JSON.stringify({
+              status: "COMPLETE",
+            }),
+            headers: {
+              mockHeaderKey: "mockHeaderValue",
+            },
+          }),
+        );
+
+        result = await getBiometricSession(
+          "https://mockUrl.com",
+          mockBiometricSessionId,
+          "mockSubmitterKey",
+          mockSendHttpRequest,
+        );
+      });
+
+      it("Logs error", () => {
+        expect(consoleErrorSpy).toHaveBeenCalledWithLogFields({
+          messageCode:
+            "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE",
+        });
+      });
+
+      it("Returns an error result with non-retryable flag", () => {
+        expect(result.isError).toBe(true);
+        expect(result.value).toEqual({
+          isRetryable: false,
+        });
+      });
+    });
+  });
+
+  describe("Given valid request is made", () => {
+    const mockSession = {
+      id: mockBiometricSessionId,
+      finish: "DONE",
+    };
+
+    beforeEach(async () => {
+      mockSendHttpRequest = jest.fn().mockResolvedValue(
+        successResult({
+          statusCode: 200,
+          body: JSON.stringify(mockSession),
+          headers: {
+            mockHeaderKey: "mockHeaderValue",
+          },
+        }),
+      );
+
+      result = await getBiometricSession(
+        "https://mockUrl.com",
+        mockBiometricSessionId,
+        "mockSubmitterKey",
+        mockSendHttpRequest,
+      );
+    });
+
+    it("Logs success at debug level", () => {
+      expect(consoleDebugSpy).toHaveBeenCalledWithLogFields({
+        messageCode:
+          "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_SUCCESS",
+      });
+    });
+
+    it("it returns successResult containing biometric session", () => {
+      expect(result).toEqual(successResult(mockSession));
+      expect(mockSendHttpRequest).toBeCalledWith(
+        expectedHttpRequest,
+        expectedRetryConfig,
+      );
+    });
+  });
+});
diff --git a/backend-api/src/functions/asyncIssueBiometricCredential/getBiometricSession/getBiometricSession.ts b/backend-api/src/functions/asyncIssueBiometricCredential/getBiometricSession/getBiometricSession.ts
new file mode 100644
index 000000000..d958a51a8
--- /dev/null
+++ b/backend-api/src/functions/asyncIssueBiometricCredential/getBiometricSession/getBiometricSession.ts
@@ -0,0 +1,137 @@
+import { logger } from "../../common/logging/logger";
+import { LogMessage } from "../../common/logging/LogMessage";
+import {
+  HttpError,
+  ISendHttpRequest,
+  sendHttpRequest as sendHttpRequestDefault,
+  SuccessfulHttpResponse,
+} from "../../adapters/http/sendHttpRequest";
+import { Result, successResult, errorResult } from "../../utils/result";
+
+export interface BiometricSession {
+  finish: string;
+}
+
+export interface GetBiometricSessionError {
+  isRetryable: boolean;
+}
+
+export type GetBiometricSession = (
+  baseUrl: string,
+  biometricSessionId: string,
+  viewerKey: string,
+  sendHttpRequest?: ISendHttpRequest,
+) => Promise<Result<BiometricSession, GetBiometricSessionError>>;
+
+export const getBiometricSession: GetBiometricSession = async (
+  baseUrl: string,
+  biometricSessionId: string,
+  viewerKey: string,
+  sendHttpRequest: ISendHttpRequest = sendHttpRequestDefault,
+) => {
+  const httpRequest = {
+    url: `${baseUrl}/odata/v1/ODataServlet/Sessions('${biometricSessionId}')`,
+    method: "GET" as const,
+    headers: {
+      "X-Innovalor-Authorization": viewerKey,
+      Accept: "application/json;odata.metadata=minimal",
+      "Content-Type": "application/json;odata.metadata=minimal",
+      "OData-MaxVersion": "4.0",
+      "OData-Version": "4.0",
+    },
+  };
+
+  const retryConfig = {
+    retryableStatusCodes: [
+      429, 500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511,
+    ],
+    delayInMillis: 50,
+  };
+
+  logger.debug(LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_ATTEMPT);
+
+  const getBiometricSessionResult: Result<SuccessfulHttpResponse, HttpError> =
+    await sendHttpRequest(httpRequest, retryConfig);
+
+  if (getBiometricSessionResult.isError) {
+    const error = getBiometricSessionResult.value;
+    const statusCode = error.statusCode ? Number(error.statusCode) : undefined;
+    const retryableStatusCodes = retryConfig.retryableStatusCodes;
+
+    const isRetryable =
+      !statusCode || retryableStatusCodes.includes(statusCode);
+
+    logger.error(
+      LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE,
+      {
+        data: {
+          error,
+          isRetryable,
+        },
+      },
+    );
+
+    return errorResult({
+      isRetryable,
+    });
+  }
+
+  const getBiometricSessionResponse = getBiometricSessionResult.value;
+
+  if (getBiometricSessionResponse?.body == null) {
+    logger.error(
+      LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE,
+      {
+        data: {
+          getBiometricSessionResponse,
+        },
+      },
+    );
+
+    return errorResult({
+      isRetryable: false,
+    });
+  }
+
+  let parsedBody;
+  try {
+    parsedBody = JSON.parse(getBiometricSessionResponse.body);
+  } catch (error) {
+    logger.error(
+      LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE,
+      {
+        data: {
+          error,
+        },
+      },
+    );
+
+    return errorResult({
+      isRetryable: false,
+    });
+  }
+
+  // Check if the response has the expected structure
+  if (!parsedBody || typeof parsedBody.finish !== "string") {
+    logger.error(
+      LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE,
+      {
+        data: {
+          invalidFinishProperty: parsedBody?.finish,
+        },
+      },
+    );
+
+    return errorResult({
+      isRetryable: false,
+    });
+  }
+
+  logger.debug(LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_SUCCESS, {
+    data: {
+      finish: parsedBody.finish,
+    },
+  });
+
+  return successResult(parsedBody);
+};
diff --git a/backend-api/src/functions/asyncIssueBiometricCredential/handlerDependencies.ts b/backend-api/src/functions/asyncIssueBiometricCredential/handlerDependencies.ts
index f16ed0a71..894898d6f 100644
--- a/backend-api/src/functions/asyncIssueBiometricCredential/handlerDependencies.ts
+++ b/backend-api/src/functions/asyncIssueBiometricCredential/handlerDependencies.ts
@@ -2,6 +2,15 @@ import { DynamoDbAdapter } from "../adapters/aws/dynamo/dynamoDbAdapter";
 import { SessionRegistry } from "../common/session/SessionRegistry/SessionRegistry";
 import { GetSecrets } from "../common/config/secrets";
 import { getSecretsFromParameterStore } from "../adapters/aws/parameterStore/getSecretsFromParameterStore";
+
+import {
+  GetBiometricSession,
+  getBiometricSession,
+} from "./getBiometricSession/getBiometricSession";
+import { Result } from "../utils/result";
+import { sendMessageToSqs } from "../adapters/aws/sqs/sendMessageToSqs";
+import { OutboundQueueErrorMessage } from "../adapters/aws/sqs/types";
+
 import { IEventService } from "../services/events/types";
 import { EventService } from "../services/events/eventService";
 
@@ -9,12 +18,19 @@ export type IssueBiometricCredentialDependencies = {
   env: NodeJS.ProcessEnv;
   getSessionRegistry: (tableName: string) => SessionRegistry;
   getSecrets: GetSecrets;
+  getBiometricSession: GetBiometricSession;
   getEventService: (sqsQueue: string) => IEventService;
+  sendMessageToSqs: (
+    sqsArn: string,
+    messageBody: OutboundQueueErrorMessage,
+  ) => Promise<Result<void, void>>;
 };
 
 export const runtimeDependencies: IssueBiometricCredentialDependencies = {
   env: process.env,
   getSessionRegistry: (tableName: string) => new DynamoDbAdapter(tableName),
   getSecrets: getSecretsFromParameterStore,
+  getBiometricSession,
   getEventService: (sqsQueue: string) => new EventService(sqsQueue),
+  sendMessageToSqs,
 };
diff --git a/backend-api/src/functions/asyncIssueBiometricCredential/issueBiometricCredentialConfig.ts b/backend-api/src/functions/asyncIssueBiometricCredential/issueBiometricCredentialConfig.ts
index 6cbc99214..9c9c7b453 100644
--- a/backend-api/src/functions/asyncIssueBiometricCredential/issueBiometricCredentialConfig.ts
+++ b/backend-api/src/functions/asyncIssueBiometricCredential/issueBiometricCredentialConfig.ts
@@ -8,8 +8,10 @@ import { logger } from "../common/logging/logger";
 import { LogMessage } from "../common/logging/LogMessage";
 
 const REQUIRED_ENVIRONMENT_VARIABLES = [
+  "READID_BASE_URL",
   "BIOMETRIC_VIEWER_KEY_SECRET_PATH",
   "BIOMETRIC_VIEWER_ACCESS_KEY_SECRET_CACHE_DURATION_IN_SECONDS",
+  "IPVCORE_OUTBOUND_SQS",
   "SESSION_TABLE_NAME",
   "TXMA_SQS",
   "ISSUER",
diff --git a/backend-api/src/functions/common/logging/LogMessage.ts b/backend-api/src/functions/common/logging/LogMessage.ts
index 3be5ed9dd..76655a6b2 100644
--- a/backend-api/src/functions/common/logging/LogMessage.ts
+++ b/backend-api/src/functions/common/logging/LogMessage.ts
@@ -311,6 +311,34 @@ export class LogMessage implements LogAttributes {
     "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_INVALID_CONFIG",
     "One or more required environment variables were missing or invalid.",
   );
+
+  static readonly ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE =
+    new LogMessage(
+      "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE",
+      "Failed to retrieve biometric session from ReadID",
+    );
+  static readonly ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_SUCCESS =
+    new LogMessage(
+      "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_SUCCESS",
+      "Successfully retrieved biometric session from ReadID",
+    );
+  static readonly ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_ATTEMPT =
+    new LogMessage(
+      "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_ATTEMPT",
+      "Retrieving biometric session from ReadID",
+    );
+  static readonly ISSUE_BIOMETRIC_CREDENTIAL_BIOMETRIC_SESSION_NOT_READY =
+    new LogMessage(
+      "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_NOT_READY",
+      "Biometric session not ready (finish status not DONE)",
+    );
+
+  static readonly ISSUE_BIOMETRIC_CREDENTIAL_IPV_CORE_MESSAGE_ERROR =
+    new LogMessage(
+      "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_IPV_CORE_MESSAGE_ERROR",
+      "Error sending message to IPV Core",
+    );
+
   static readonly ISSUE_BIOMETRIC_CREDENTIAL_INVALID_SQS_EVENT = new LogMessage(
     "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_INVALID_SQS_EVENT",
     "SQS Event from vendor processing queue is invalid",
diff --git a/backend-api/src/functions/testUtils/unitTestData.ts b/backend-api/src/functions/testUtils/unitTestData.ts
index 5f57b949b..c6ecf256b 100644
--- a/backend-api/src/functions/testUtils/unitTestData.ts
+++ b/backend-api/src/functions/testUtils/unitTestData.ts
@@ -1,7 +1,12 @@
 import { SessionState } from "../common/session/session";
 import { SessionRegistry } from "../common/session/SessionRegistry/SessionRegistry";
 import { IEventService } from "../services/events/types";
-import { emptyFailure, emptySuccess, errorResult } from "../utils/result";
+import {
+  emptyFailure,
+  emptySuccess,
+  errorResult,
+  successResult,
+} from "../utils/result";
 
 export const mockSessionId = "58f4281d-d988-49ce-9586-6ef70a2be0b4";
 export const mockBiometricSessionId = "f32432a9-0965-4da9-8a2c-a98a79349d4a";
@@ -150,3 +155,7 @@ export const mockSuccessfulSendMessageToSqs = jest
 export const mockFailingSendMessageToSqs = jest
   .fn()
   .mockResolvedValue(emptyFailure());
+
+export const mockGetBiometricSessionSuccess = jest
+  .fn()
+  .mockResolvedValue(successResult("mockBiometricSession"));
diff --git a/backend-api/template.yaml b/backend-api/template.yaml
index f091d3ea6..8aefe8cd9 100644
--- a/backend-api/template.yaml
+++ b/backend-api/template.yaml
@@ -3292,6 +3292,13 @@ Resources:
             - EnvironmentVariables
             - !Ref Environment
             - BiometricViewerAccessKey
+          READID_BASE_URL: !If
+            - UseDevOverrideReadIdBaseUrl
+            - !Ref DevOverrideReadIdBaseUrl
+            - !FindInMap
+              - EnvironmentVariables
+              - !Ref Environment
+              - ReadIdBaseUrl
       FunctionName: !Sub ${AWS::StackName}-issue-biometric-credential
       Handler: asyncIssueBiometricCredentialHandler.lambdaHandler
       ReservedConcurrentExecutions: !If
@@ -3385,9 +3392,23 @@ Resources:
                   - sqs:GetQueueAttributes
                   - sqs:ChangeMessageVisibility
                 Effect: Allow
-                Resource: !GetAtt VendorProcessingSQS.Arn
+                Resource:
+                  - !GetAtt VendorProcessingSQS.Arn
             Version: "2012-10-17"
           PolicyName: SQSPolicy
+        - PolicyDocument:
+            Statement:
+              - Action:
+                  - sqs:SendMessage
+                Effect: Allow
+                Resource: !GetAtt IPVCoreSQS.Arn
+              - Action:
+                  - kms:Decrypt
+                  - kms:GenerateDataKey
+                Effect: Allow
+                Resource: !GetAtt IPVCoreKMSEncryptionKey.Arn
+            Version: "2012-10-17"
+          PolicyName: AsyncIssueBiometricCredentialFunctionIPVCoreSQSPolicy
         - PolicyDocument:
             Statement:
               - Action:


Reviews:
user: login: jmooney-dd
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T14:58:21Z
user: login: jmooney-dd
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T14:59:31Z
user: login: jmooney-dd
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T14:59:50Z
user: login: jmooney-dd
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T15:00:49Z
user: login: jmooney-dd
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T15:02:01Z
user: login: jmooney-dd
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T15:02:10Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T09:30:09Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T09:30:27Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T09:33:36Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T09:37:15Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T09:41:37Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T09:45:01Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T09:49:39Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T09:51:44Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T09:52:43Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T09:54:48Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T10:01:07Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T10:02:08Z
user: login: tmulberry-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T10:09:50Z
user: login: tmulberry-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T12:57:36Z
user: login: tmulberry-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T12:58:41Z
user: login: tmulberry-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T13:02:36Z
user: login: tmulberry-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T14:30:06Z
user: login: tmulberry-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T14:30:12Z
user: login: tmulberry-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T14:30:41Z
user: login: tmulberry-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T14:30:51Z
user: login: tmulberry-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T14:32:09Z
user: login: tmulberry-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T14:34:21Z
user: login: tmulberry-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T14:45:32Z
user: login: tmulberry-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T14:46:37Z
user: login: tmulberry-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T14:46:47Z
user: login: tmulberry-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T14:47:19Z
user: login: tmulberry-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T14:56:44Z
user: login: tmulberry-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T14:57:27Z
user: login: tmulberry-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T15:05:29Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T15:31:32Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T15:37:56Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T15:39:33Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T15:40:25Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T15:44:29Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T15:45:56Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T15:46:38Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T15:47:43Z
user: login: sandymay-dd
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T16:08:37Z
user: login: tmulberry-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T08:19:27Z
user: login: tmulberry-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T08:22:16Z
user: login: tmulberry-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T08:24:22Z
user: login: tmulberry-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T08:24:58Z
user: login: tmulberry-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T08:25:26Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T08:49:19Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T08:50:08Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T08:51:30Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T08:51:43Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T08:52:00Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T08:54:00Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T08:57:03Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T08:57:52Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T09:00:57Z
user: login: jmooney-dd
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T09:03:05Z
user: login: jmooney-dd
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T09:05:27Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T09:05:44Z
user: login: jmooney-dd
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T09:06:57Z
user: login: jmooney-dd
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T09:08:46Z
user: login: jmooney-dd
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T09:09:49Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T09:09:53Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T09:10:54Z
user: login: jmooney-dd
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T09:11:25Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T09:11:36Z
user: login: tmulberry-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T09:11:55Z
user: login: jmooney-dd
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T09:11:56Z
user: login: tmulberry-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T09:12:45Z
user: login: jmooney-dd
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T09:12:52Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T09:14:21Z
user: login: tmulberry-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T09:15:04Z
user: login: jmooney-dd
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T09:15:16Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T09:15:48Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T09:16:41Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T09:17:37Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T09:18:23Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T09:19:38Z
user: login: tmulberry-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T09:19:43Z
user: login: tmulberry-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T09:19:48Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T09:20:12Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T09:21:58Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T09:23:42Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T09:25:32Z
user: login: jmooney-dd
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T09:26:50Z
user: login: tmulberry-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T09:28:46Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T09:28:54Z
user: login: tmulberry-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T09:30:46Z
user: login: tmulberry-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T09:35:09Z
user: login: tmulberry-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T09:36:42Z
user: login: tmulberry-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T09:37:13Z
user: login: tmulberry-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T09:38:16Z
user: login: tmulberry-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T09:38:45Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T10:25:27Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T11:10:53Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T11:13:47Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T11:16:37Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T14:17:34Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T14:18:10Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T14:29:51Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T14:42:27Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T14:48:55Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T14:53:57Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T14:54:48Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T14:57:24Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T15:00:46Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T15:01:36Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T15:01:50Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T15:02:36Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T15:05:33Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T15:06:02Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T15:06:33Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T15:07:08Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T15:20:44Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T08:59:33Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T09:02:34Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T09:03:16Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T09:03:44Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T09:05:32Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T09:07:32Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T09:10:28Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T09:12:20Z
user: login: kikidawson-gds
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T09:42:47Z
user: login: tmulberry-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T12:21:41Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T13:39:19Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T13:40:07Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T13:41:04Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T13:42:38Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T13:44:23Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T13:45:12Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T13:48:39Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T13:48:50Z
user: login: tmulberry-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T14:24:46Z
user: login: tmulberry-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T08:12:10Z
user: login: tmulberry-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T08:12:21Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T08:16:36Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T08:47:26Z
user: login: chris-cooksley-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T08:48:33Z
user: login: tmulberry-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T08:52:50Z
user: login: tmulberry-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T08:53:54Z
user: login: tmulberry-gds
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T08:53:59Z
user: login: chris-cooksley-gds
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T09:11:23Z
user: login: kikidawson-gds
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T10:14:37Z

Review comments:
diff_hunk: @@ -7,17 +7,33 @@ import { lambdaHandlerConstructor } from "./asyncIssueBiometricCredentialHandler
 import { RetainMessageOnQueue } from "./RetainMessageOnQueue";
 import { IssueBiometricCredentialDependencies } from "./handlerDependencies";
 import {
-  mockBiometricSessionId,
-  mockInertEventService,
-  mockInertSessionRegistry,
   mockSessionId,
   mockSuccessfulEventService,
+  mockWriteGenericEventSuccessResult,
+  mockSuccessfulSendMessageToSqs,
+  mockInertSessionRegistry,
+  mockBiometricSessionId,
+  mockInertEventService,
   validBiometricSessionFinishedAttributes,
   validResultSentAttributes,
 } from "../testUtils/unitTestData";
 import { SessionRegistry } from "../common/session/SessionRegistry/SessionRegistry";
 import { emptyFailure, errorResult, successResult } from "../utils/result";
 import { GetSessionError } from "../common/session/SessionRegistry/types";
+import { BiometricSession } from "./getBiometricSession/getBiometricSession";
+
+jest.mock("./getBiometricSession/getBiometricSession", () => {
+  return {
+    ...jest.requireActual("./getBiometricSession/getBiometricSession"),
+    isRetryableError: jest.fn(),
+    getLastError: jest.fn(),
+  };
+});
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.test.ts
user: login: jmooney-dd
body: question: How come we have to import and mock the whole module?

We don't typically mock by importing the module and mocking it directly, we normally create a mock func and inject into our deps in the unit test file.
created_at: 2025-04-15T14:58:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -370,6 +417,153 @@ describe("Async Issue Biometric Credential", () => {
     });
   });
 
+  describe("When biometric session is not ready", () => {
+    beforeEach(() => {
+      dependencies.getBiometricSession = mockGetBiometricSessionNotReady;
+    });
+
+    it("Throws RetainMessageOnQueue with appropriate message", async () => {
+      await expect(
+        lambdaHandlerConstructor(dependencies, validSqsEvent, context),
+      ).rejects.toThrow(/Biometric session not ready: PROCESSING/);
+
+      expect(consoleInfoSpy).toHaveBeenCalledWithLogFields({
+        messageCode: "MOBILE_ASYNC_BIOMETRIC_SESSION_NOT_READY",
+        data: {
+          finish: "PROCESSING",
+        },
+      });
+    });
+  });
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.test.ts
user: login: jmooney-dd
body: suggestion: see other tests in this file that use try catch in the before each, this way we can still separate the assertions, one it block for log, one for the error
created_at: 2025-04-15T14:59:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -370,6 +417,153 @@ describe("Async Issue Biometric Credential", () => {
     });
   });
 
+  describe("When biometric session is not ready", () => {
+    beforeEach(() => {
+      dependencies.getBiometricSession = mockGetBiometricSessionNotReady;
+    });
+
+    it("Throws RetainMessageOnQueue with appropriate message", async () => {
+      await expect(
+        lambdaHandlerConstructor(dependencies, validSqsEvent, context),
+      ).rejects.toThrow(/Biometric session not ready: PROCESSING/);
+
+      expect(consoleInfoSpy).toHaveBeenCalledWithLogFields({
+        messageCode: "MOBILE_ASYNC_BIOMETRIC_SESSION_NOT_READY",
+        data: {
+          finish: "PROCESSING",
+        },
+      });
+    });
+  });
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.test.ts
user: login: jmooney-dd
body: This would go for the other tests as well but I won't comment on each one
created_at: 2025-04-15T14:59:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -74,7 +90,88 @@ export async function lambdaHandlerConstructor(
   if (viewerKeyResult.isError) {
     throw new RetainMessageOnQueue("Failed to retrieve biometric viewer key");
   }
+  const submitterKey = viewerKeyResult.value;
+
+  const sessionResult = await dependencies.getBiometricSession(
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.ts
user: login: jmooney-dd
body: issue: could this const be named something idfferently so it's not confused with any session result when getting OUR session

```suggestion
  const biometricSessionResult = await dependencies.getBiometricSession(
```
created_at: 2025-04-15T15:00:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -74,7 +90,88 @@ export async function lambdaHandlerConstructor(
   if (viewerKeyResult.isError) {
     throw new RetainMessageOnQueue("Failed to retrieve biometric viewer key");
   }
+  const submitterKey = viewerKeyResult.value;
+
+  const sessionResult = await dependencies.getBiometricSession(
+    config.READID_BASE_URL,
+    sessionId,
+    submitterKey,
+  );
+
+  if (sessionResult.isError) {
+    const eventService = dependencies.getEventService(config.TXMA_SQS);
+
+    // Check if the error was retryable based on HTTP status code
+    if (isRetryableError()) {
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.ts
user: login: jmooney-dd
body: question: does this work and is this best practice?

When I read this, I wonder what it is evaluating. When clicking through I can see it's the let lastError variable in another file. Was there a reason for this approach or could we pass this func something to evaluate?
created_at: 2025-04-15T15:02:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -38,19 +45,28 @@ export async function lambdaHandlerConstructor(
   if (validateSqsEventResult.isError) {
     return;
   }
+
   const sessionId = validateSqsEventResult.value;
 
   logger.appendKeys({ sessionId });
 
   const sessionRegistry = dependencies.getSessionRegistry(
     config.SESSION_TABLE_NAME,
   );
+
   const eventService = dependencies.getEventService(config.TXMA_SQS);
 
   const getSessionResult = await sessionRegistry.getSession(
     sessionId,
-    new GetSessionIssueBiometricCredential(),
+    new GetSessionBiometricTokenIssued(),
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.ts
user: login: jmooney-dd
body: issue: this is the name of a different lambda
created_at: 2025-04-15T15:02:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,136 @@
+import { logger } from "../../common/logging/logger";
+import { LogMessage } from "../../common/logging/LogMessage";
+import {
+  HttpError,
+  ISendHttpRequest,
+  sendHttpRequest as sendHttpRequestDefault,
+  SuccessfulHttpResponse,
+} from "../../adapters/http/sendHttpRequest";
+import { emptyFailure, Result, successResult } from "../../utils/result";
+
+export interface BiometricSession {
+  id: string;
+  finish: string;
+}
+
+let lastError: HttpError | null = null;
+
+export function getLastError(): HttpError | null {
+  return lastError;
+}
+
+export function isRetryableError(): boolean {
+  if (!lastError || lastError.statusCode === undefined) {
+    return false;
+  }
+
+  const retryableStatusCodes = [
+    429, 500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511,
+  ];
+
+  return retryableStatusCodes.includes(lastError.statusCode);
+}
+
+export type GetBiometricSession = (
+  baseUrl: string,
+  sessionId: string,
+  submitterKey: string,
+  sendHttpRequest?: ISendHttpRequest,
+) => Promise<Result<BiometricSession, void>>;
+
+export const getBiometricSession: GetBiometricSession = async (
+  baseUrl: string,
+  sessionId: string,
+  submitterKey: string,
path: backend-api/src/functions/asyncIssueBiometricCredential/getBiometricSession/getBiometricSession.ts
user: login: chris-cooksley-gds
body: ```suggestion
  viewerKey: string,
```

(I've now updated this on the ticket where it was wrong)
created_at: 2025-04-16T09:30:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,136 @@
+import { logger } from "../../common/logging/logger";
+import { LogMessage } from "../../common/logging/LogMessage";
+import {
+  HttpError,
+  ISendHttpRequest,
+  sendHttpRequest as sendHttpRequestDefault,
+  SuccessfulHttpResponse,
+} from "../../adapters/http/sendHttpRequest";
+import { emptyFailure, Result, successResult } from "../../utils/result";
+
+export interface BiometricSession {
+  id: string;
+  finish: string;
+}
+
+let lastError: HttpError | null = null;
+
+export function getLastError(): HttpError | null {
+  return lastError;
+}
+
+export function isRetryableError(): boolean {
+  if (!lastError || lastError.statusCode === undefined) {
+    return false;
+  }
+
+  const retryableStatusCodes = [
+    429, 500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511,
+  ];
+
+  return retryableStatusCodes.includes(lastError.statusCode);
+}
+
+export type GetBiometricSession = (
+  baseUrl: string,
+  sessionId: string,
+  submitterKey: string,
+  sendHttpRequest?: ISendHttpRequest,
+) => Promise<Result<BiometricSession, void>>;
+
+export const getBiometricSession: GetBiometricSession = async (
+  baseUrl: string,
+  sessionId: string,
path: backend-api/src/functions/asyncIssueBiometricCredential/getBiometricSession/getBiometricSession.ts
user: login: chris-cooksley-gds
body: ```suggestion
  biometricSessionId: string,
```
created_at: 2025-04-16T09:30:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,136 @@
+import { logger } from "../../common/logging/logger";
+import { LogMessage } from "../../common/logging/LogMessage";
+import {
+  HttpError,
+  ISendHttpRequest,
+  sendHttpRequest as sendHttpRequestDefault,
+  SuccessfulHttpResponse,
+} from "../../adapters/http/sendHttpRequest";
+import { emptyFailure, Result, successResult } from "../../utils/result";
+
+export interface BiometricSession {
+  id: string;
+  finish: string;
+}
+
+let lastError: HttpError | null = null;
+
+export function getLastError(): HttpError | null {
+  return lastError;
+}
+
+export function isRetryableError(): boolean {
+  if (!lastError || lastError.statusCode === undefined) {
+    return false;
+  }
+
+  const retryableStatusCodes = [
+    429, 500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511,
+  ];
+
+  return retryableStatusCodes.includes(lastError.statusCode);
+}
+
+export type GetBiometricSession = (
+  baseUrl: string,
+  sessionId: string,
+  submitterKey: string,
+  sendHttpRequest?: ISendHttpRequest,
+) => Promise<Result<BiometricSession, void>>;
+
+export const getBiometricSession: GetBiometricSession = async (
+  baseUrl: string,
+  sessionId: string,
+  submitterKey: string,
+  sendHttpRequest: ISendHttpRequest = sendHttpRequestDefault,
+) => {
+  const httpRequest = {
+    url: `${baseUrl}/odata/v1/ODataServlet/Sessions('${sessionId}')`,
+    method: "GET" as const,
+    headers: {
+      "X-Innovalor-Authorization": submitterKey,
+      Accept: "application/json;odata.metadata=minimal",
+      "Content-Type": "application/json;odata.metadata=minimal",
+      "OData-MaxVersion": "4.0",
+      "OData-Version": "4.0",
+    },
+  };
+  const httpRequestLogData = {
path: backend-api/src/functions/asyncIssueBiometricCredential/getBiometricSession/getBiometricSession.ts
user: login: chris-cooksley-gds
body: Do we need to log any of this data at all? The only things that are dynamic are:
* the biometric session ID - which we're already logging below anyway
* the viewer key, which gets redacted anyway

Everything else (url, header values etc) should always be the same - and therefore won't give us any information we don't already have.
created_at: 2025-04-16T09:33:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,136 @@
+import { logger } from "../../common/logging/logger";
+import { LogMessage } from "../../common/logging/LogMessage";
+import {
+  HttpError,
+  ISendHttpRequest,
+  sendHttpRequest as sendHttpRequestDefault,
+  SuccessfulHttpResponse,
+} from "../../adapters/http/sendHttpRequest";
+import { emptyFailure, Result, successResult } from "../../utils/result";
+
+export interface BiometricSession {
+  id: string;
+  finish: string;
+}
+
+let lastError: HttpError | null = null;
+
+export function getLastError(): HttpError | null {
+  return lastError;
+}
+
+export function isRetryableError(): boolean {
+  if (!lastError || lastError.statusCode === undefined) {
+    return false;
+  }
+
+  const retryableStatusCodes = [
+    429, 500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511,
+  ];
+
+  return retryableStatusCodes.includes(lastError.statusCode);
+}
+
+export type GetBiometricSession = (
+  baseUrl: string,
+  sessionId: string,
+  submitterKey: string,
+  sendHttpRequest?: ISendHttpRequest,
+) => Promise<Result<BiometricSession, void>>;
+
+export const getBiometricSession: GetBiometricSession = async (
+  baseUrl: string,
+  sessionId: string,
+  submitterKey: string,
+  sendHttpRequest: ISendHttpRequest = sendHttpRequestDefault,
+) => {
+  const httpRequest = {
+    url: `${baseUrl}/odata/v1/ODataServlet/Sessions('${sessionId}')`,
+    method: "GET" as const,
+    headers: {
+      "X-Innovalor-Authorization": submitterKey,
+      Accept: "application/json;odata.metadata=minimal",
+      "Content-Type": "application/json;odata.metadata=minimal",
+      "OData-MaxVersion": "4.0",
+      "OData-Version": "4.0",
+    },
+  };
+  const httpRequestLogData = {
+    ...httpRequest,
+    headers: {
+      ...httpRequest.headers,
+      "X-Innovalor-Authorization": "Secret value and cannot be logged",
+    },
+  };
+  const retryConfig = {
path: backend-api/src/functions/asyncIssueBiometricCredential/getBiometricSession/getBiometricSession.ts
user: login: chris-cooksley-gds
body: This doesn't look like it matches the `RetryConfig` type expected by `sendHttpRequest` - is typescript ok with this?
created_at: 2025-04-16T09:37:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,136 @@
+import { logger } from "../../common/logging/logger";
+import { LogMessage } from "../../common/logging/LogMessage";
+import {
+  HttpError,
+  ISendHttpRequest,
+  sendHttpRequest as sendHttpRequestDefault,
+  SuccessfulHttpResponse,
+} from "../../adapters/http/sendHttpRequest";
+import { emptyFailure, Result, successResult } from "../../utils/result";
+
+export interface BiometricSession {
+  id: string;
+  finish: string;
+}
+
+let lastError: HttpError | null = null;
+
+export function getLastError(): HttpError | null {
+  return lastError;
+}
+
+export function isRetryableError(): boolean {
+  if (!lastError || lastError.statusCode === undefined) {
+    return false;
+  }
+
+  const retryableStatusCodes = [
+    429, 500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511,
+  ];
+
+  return retryableStatusCodes.includes(lastError.statusCode);
+}
+
+export type GetBiometricSession = (
+  baseUrl: string,
+  sessionId: string,
+  submitterKey: string,
+  sendHttpRequest?: ISendHttpRequest,
+) => Promise<Result<BiometricSession, void>>;
+
+export const getBiometricSession: GetBiometricSession = async (
+  baseUrl: string,
+  sessionId: string,
+  submitterKey: string,
+  sendHttpRequest: ISendHttpRequest = sendHttpRequestDefault,
+) => {
+  const httpRequest = {
+    url: `${baseUrl}/odata/v1/ODataServlet/Sessions('${sessionId}')`,
+    method: "GET" as const,
+    headers: {
+      "X-Innovalor-Authorization": submitterKey,
+      Accept: "application/json;odata.metadata=minimal",
+      "Content-Type": "application/json;odata.metadata=minimal",
+      "OData-MaxVersion": "4.0",
+      "OData-Version": "4.0",
+    },
+  };
+  const httpRequestLogData = {
+    ...httpRequest,
+    headers: {
+      ...httpRequest.headers,
+      "X-Innovalor-Authorization": "Secret value and cannot be logged",
+    },
+  };
+  const retryConfig = {
+    retryableStatusCodes: [
+      429, 500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511,
+    ],
+    maxRetries: 3,
+    initialDelayMs: 50,
+    useExponentialBackoff: true,
+    useJitter: true,
+  };
+
+  logger.debug(LogMessage.BIOMETRIC_SESSION_GET_FROM_READID_ATTEMPT, {
+    data: {
+      httpRequest: httpRequestLogData,
+      sessionId,
+    },
+  });
+
+  const getBiometricSessionResult: Result<SuccessfulHttpResponse, HttpError> =
+    await sendHttpRequest(httpRequest, retryConfig);
+
+  if (getBiometricSessionResult.isError) {
+    lastError = getBiometricSessionResult.value;
path: backend-api/src/functions/asyncIssueBiometricCredential/getBiometricSession/getBiometricSession.ts
user: login: chris-cooksley-gds
body: Rather than using this `lastError` variable to share state with the handler, we could:
* return an error object instead of an empty failure from this function, which would indicate whether the failure was retryable or not
* just throw the RetainMessageOnQueue exception here if the failure is retryable
created_at: 2025-04-16T09:41:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -74,7 +90,88 @@ export async function lambdaHandlerConstructor(
   if (viewerKeyResult.isError) {
     throw new RetainMessageOnQueue("Failed to retrieve biometric viewer key");
   }
+  const submitterKey = viewerKeyResult.value;
+
+  const sessionResult = await dependencies.getBiometricSession(
+    config.READID_BASE_URL,
+    sessionId,
+    submitterKey,
+  );
+
+  if (sessionResult.isError) {
+    const eventService = dependencies.getEventService(config.TXMA_SQS);
+
+    // Check if the error was retryable based on HTTP status code
+    if (isRetryableError()) {
+      const error = getLastError();
+      logger.error(LogMessage.BIOMETRIC_SESSION_RETRYABLE_ERROR);
+      throw new RetainMessageOnQueue(
+        `Retryable error (${Number(error?.statusCode)}) retrieving biometric session`,
+      );
+    }
+
+    // Non-retryable error - send error to IPV Core
+    logger.error(LogMessage.BIOMETRIC_SESSION_NON_RETRYABLE_ERROR, {
+      data: {
+        sessionId,
+        error: getLastError(),
+      },
+    });
+
+    if (
+      sessionAttributes?.subjectIdentifier &&
+      sessionAttributes?.clientState
+    ) {
+      const ipvCoreOutboundMessage: IssueBiometricCredentialMessage = {
+        sub: sessionAttributes.subjectIdentifier,
+        state: sessionAttributes.clientState,
+        error: "server_error",
+        error_description: "Failed to retrieve biometric session from ReadID",
+      };
+
+      const sendMessageToIPVCoreOutboundQueueResult =
+        await dependencies.sendMessageToSqs(
+          config.IPVCORE_OUTBOUND_SQS,
+          ipvCoreOutboundMessage,
+        );
+
+      if (sendMessageToIPVCoreOutboundQueueResult.isError) {
+        logger.error(LogMessage.BIOMETRIC_SESSION_IPV_CORE_MESSAGE_ERROR);
+      }
+    }
+
+    const writeEventResult = await eventService.writeGenericEvent({
+      eventName: "DCMAW_ASYNC_CRI_5XXERROR",
+      sub: sessionAttributes?.subjectIdentifier,
+      sessionId,
+      govukSigninJourneyId: sessionAttributes?.govukSigninJourneyId,
+      getNowInMilliseconds: Date.now,
+      componentId: config.ISSUER,
+      ipAddress: undefined,
+      txmaAuditEncoded: undefined,
+      redirect_uri: sessionAttributes?.redirectUri,
+      suspected_fraud_signal: undefined,
+    });
+
+    if (writeEventResult.isError) {
+      logger.error(LogMessage.BIOMETRIC_SESSION_TXMA_EVENT_ERROR);
+    }
+
+    return;
+  }
+
+  const session = sessionResult.value;
+
+  if (session.finish !== "DONE") {
+    logger.info(LogMessage.BIOMETRIC_SESSION_NOT_READY, {
+      data: { finish: session.finish },
+    });
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.ts
user: login: chris-cooksley-gds
body: I _think_ we should also log completion here, per the ticket. The rationale being that this isn't an error scenario, so we wouldn't want it to drive canary rollbacks etc.
created_at: 2025-04-16T09:45:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -370,6 +417,153 @@ describe("Async Issue Biometric Credential", () => {
     });
   });
 
+  describe("When biometric session is not ready", () => {
+    beforeEach(() => {
+      dependencies.getBiometricSession = mockGetBiometricSessionNotReady;
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.test.ts
user: login: chris-cooksley-gds
body: Based on our recent Dev Time discussion around unit testing and mocking - do we need to mock this, and have a separate unit test suite directly testing `getBiometricSession`? I think the pattern we agreed on for cases like this (where the dependency isn't shared, or an adapter), was to use the real dependency and test said dependency exclusively through these handler tests - splitting into a separate file if needed for readability.

This comment is non-blocking though as it's a new pattern for our test structuring and this would be the first time using it.
created_at: 2025-04-16T09:49:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -38,19 +45,28 @@ export async function lambdaHandlerConstructor(
   if (validateSqsEventResult.isError) {
     return;
   }
+
   const sessionId = validateSqsEventResult.value;
 
   logger.appendKeys({ sessionId });
 
   const sessionRegistry = dependencies.getSessionRegistry(
     config.SESSION_TABLE_NAME,
   );
+
   const eventService = dependencies.getEventService(config.TXMA_SQS);
 
   const getSessionResult = await sessionRegistry.getSession(
     sessionId,
-    new GetSessionIssueBiometricCredential(),
+    new GetSessionBiometricTokenIssued(),
   );
+
+  if (getSessionResult.isError) {
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.ts
user: login: chris-cooksley-gds
body: Why has this been added? I think our session error handling (including logging) should already be complete.
created_at: 2025-04-16T09:51:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -74,7 +90,88 @@ export async function lambdaHandlerConstructor(
   if (viewerKeyResult.isError) {
     throw new RetainMessageOnQueue("Failed to retrieve biometric viewer key");
   }
+  const submitterKey = viewerKeyResult.value;
+
+  const sessionResult = await dependencies.getBiometricSession(
+    config.READID_BASE_URL,
+    sessionId,
+    submitterKey,
+  );
+
+  if (sessionResult.isError) {
+    const eventService = dependencies.getEventService(config.TXMA_SQS);
+
+    // Check if the error was retryable based on HTTP status code
+    if (isRetryableError()) {
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.ts
user: login: chris-cooksley-gds
body: Seconding this point - see my comment in `getBiometricSession` about other ways to go about this.
created_at: 2025-04-16T09:52:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -311,6 +311,45 @@ export class LogMessage implements LogAttributes {
     "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_INVALID_CONFIG",
     "One or more required environment variables were missing or invalid.",
   );
+
+  static readonly BIOMETRIC_SESSION_GET_FROM_READID_FAILURE = new LogMessage(
+    "MOBILE_ASYNC_BIOMETRIC_SESSION_GET_FROM_READID_FAILURE",
path: backend-api/src/functions/common/logging/LogMessage.ts
user: login: chris-cooksley-gds
body: Can we follow the same pattern as our other lambda-specific logs? (e.g. `ISSUE_BIOMETRIC_CREDENTIAL_GET_BIOMETRIC_SESSION_FAILURE`)
created_at: 2025-04-16T09:54:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,8 +1,16 @@
-type RetainMessageOnQueueMessages =
+type StaticRetainMessages =
path: backend-api/src/functions/asyncIssueBiometricCredential/RetainMessageOnQueue.ts
user: login: chris-cooksley-gds
body: Nitpick: what's the benefit of splitting these out? In either scenario we're going to retry - we really just need a set of strings to put in the error.
created_at: 2025-04-16T10:01:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,8 +1,16 @@
-type RetainMessageOnQueueMessages =
+type StaticRetainMessages =
   | "Invalid config"
   | "Failed to retrieve biometric viewer key"
   | "Unexpected failure retrieving session from database";
 
+type RetryableErrorMessage =
+  | `Retryable error (${number}) retrieving biometric session`
+  | `Biometric session not ready: ${string}`;
path: backend-api/src/functions/asyncIssueBiometricCredential/RetainMessageOnQueue.ts
user: login: chris-cooksley-gds
body: Nitpick: I know technically it's used for a message in an `Error` subclass (because of how we've implemented retries) - but it doesn't feel right calling this second one an _error message_ as the session not being ready isn't really an error, it's just a normal thing that can happen sometimes.
created_at: 2025-04-16T10:02:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,136 @@
+import { logger } from "../../common/logging/logger";
+import { LogMessage } from "../../common/logging/LogMessage";
+import {
+  HttpError,
+  ISendHttpRequest,
+  sendHttpRequest as sendHttpRequestDefault,
+  SuccessfulHttpResponse,
+} from "../../adapters/http/sendHttpRequest";
+import { emptyFailure, Result, successResult } from "../../utils/result";
+
+export interface BiometricSession {
+  id: string;
+  finish: string;
+}
+
+let lastError: HttpError | null = null;
+
+export function getLastError(): HttpError | null {
+  return lastError;
+}
+
+export function isRetryableError(): boolean {
+  if (!lastError || lastError.statusCode === undefined) {
+    return false;
+  }
+
+  const retryableStatusCodes = [
+    429, 500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511,
+  ];
+
+  return retryableStatusCodes.includes(lastError.statusCode);
+}
+
+export type GetBiometricSession = (
+  baseUrl: string,
+  sessionId: string,
+  submitterKey: string,
+  sendHttpRequest?: ISendHttpRequest,
+) => Promise<Result<BiometricSession, void>>;
+
+export const getBiometricSession: GetBiometricSession = async (
+  baseUrl: string,
+  sessionId: string,
+  submitterKey: string,
path: backend-api/src/functions/asyncIssueBiometricCredential/getBiometricSession/getBiometricSession.ts
user: login: tmulberry-gds
body: Updated
created_at: 2025-04-16T10:09:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,136 @@
+import { logger } from "../../common/logging/logger";
+import { LogMessage } from "../../common/logging/LogMessage";
+import {
+  HttpError,
+  ISendHttpRequest,
+  sendHttpRequest as sendHttpRequestDefault,
+  SuccessfulHttpResponse,
+} from "../../adapters/http/sendHttpRequest";
+import { emptyFailure, Result, successResult } from "../../utils/result";
+
+export interface BiometricSession {
+  id: string;
+  finish: string;
+}
+
+let lastError: HttpError | null = null;
+
+export function getLastError(): HttpError | null {
+  return lastError;
+}
+
+export function isRetryableError(): boolean {
+  if (!lastError || lastError.statusCode === undefined) {
+    return false;
+  }
+
+  const retryableStatusCodes = [
+    429, 500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511,
+  ];
+
+  return retryableStatusCodes.includes(lastError.statusCode);
+}
+
+export type GetBiometricSession = (
+  baseUrl: string,
+  sessionId: string,
+  submitterKey: string,
+  sendHttpRequest?: ISendHttpRequest,
+) => Promise<Result<BiometricSession, void>>;
+
+export const getBiometricSession: GetBiometricSession = async (
+  baseUrl: string,
+  sessionId: string,
path: backend-api/src/functions/asyncIssueBiometricCredential/getBiometricSession/getBiometricSession.ts
user: login: tmulberry-gds
body: Changed to biometricSessionId
created_at: 2025-04-16T12:57:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,136 @@
+import { logger } from "../../common/logging/logger";
+import { LogMessage } from "../../common/logging/LogMessage";
+import {
+  HttpError,
+  ISendHttpRequest,
+  sendHttpRequest as sendHttpRequestDefault,
+  SuccessfulHttpResponse,
+} from "../../adapters/http/sendHttpRequest";
+import { emptyFailure, Result, successResult } from "../../utils/result";
+
+export interface BiometricSession {
+  id: string;
+  finish: string;
+}
+
+let lastError: HttpError | null = null;
+
+export function getLastError(): HttpError | null {
+  return lastError;
+}
+
+export function isRetryableError(): boolean {
+  if (!lastError || lastError.statusCode === undefined) {
+    return false;
+  }
+
+  const retryableStatusCodes = [
+    429, 500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511,
+  ];
+
+  return retryableStatusCodes.includes(lastError.statusCode);
+}
+
+export type GetBiometricSession = (
+  baseUrl: string,
+  sessionId: string,
+  submitterKey: string,
+  sendHttpRequest?: ISendHttpRequest,
+) => Promise<Result<BiometricSession, void>>;
+
+export const getBiometricSession: GetBiometricSession = async (
+  baseUrl: string,
+  sessionId: string,
+  submitterKey: string,
+  sendHttpRequest: ISendHttpRequest = sendHttpRequestDefault,
+) => {
+  const httpRequest = {
+    url: `${baseUrl}/odata/v1/ODataServlet/Sessions('${sessionId}')`,
+    method: "GET" as const,
+    headers: {
+      "X-Innovalor-Authorization": submitterKey,
+      Accept: "application/json;odata.metadata=minimal",
+      "Content-Type": "application/json;odata.metadata=minimal",
+      "OData-MaxVersion": "4.0",
+      "OData-Version": "4.0",
+    },
+  };
+  const httpRequestLogData = {
path: backend-api/src/functions/asyncIssueBiometricCredential/getBiometricSession/getBiometricSession.ts
user: login: tmulberry-gds
body: Yeah I can remove it, was mostly just going to be for my own confidence when running it. Removed
created_at: 2025-04-16T12:58:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -74,7 +90,88 @@ export async function lambdaHandlerConstructor(
   if (viewerKeyResult.isError) {
     throw new RetainMessageOnQueue("Failed to retrieve biometric viewer key");
   }
+  const submitterKey = viewerKeyResult.value;
+
+  const sessionResult = await dependencies.getBiometricSession(
+    config.READID_BASE_URL,
+    sessionId,
+    submitterKey,
+  );
+
+  if (sessionResult.isError) {
+    const eventService = dependencies.getEventService(config.TXMA_SQS);
+
+    // Check if the error was retryable based on HTTP status code
+    if (isRetryableError()) {
+      const error = getLastError();
+      logger.error(LogMessage.BIOMETRIC_SESSION_RETRYABLE_ERROR);
+      throw new RetainMessageOnQueue(
+        `Retryable error (${Number(error?.statusCode)}) retrieving biometric session`,
+      );
+    }
+
+    // Non-retryable error - send error to IPV Core
+    logger.error(LogMessage.BIOMETRIC_SESSION_NON_RETRYABLE_ERROR, {
+      data: {
+        sessionId,
+        error: getLastError(),
+      },
+    });
+
+    if (
+      sessionAttributes?.subjectIdentifier &&
+      sessionAttributes?.clientState
+    ) {
+      const ipvCoreOutboundMessage: IssueBiometricCredentialMessage = {
+        sub: sessionAttributes.subjectIdentifier,
+        state: sessionAttributes.clientState,
+        error: "server_error",
+        error_description: "Failed to retrieve biometric session from ReadID",
+      };
+
+      const sendMessageToIPVCoreOutboundQueueResult =
+        await dependencies.sendMessageToSqs(
+          config.IPVCORE_OUTBOUND_SQS,
+          ipvCoreOutboundMessage,
+        );
+
+      if (sendMessageToIPVCoreOutboundQueueResult.isError) {
+        logger.error(LogMessage.BIOMETRIC_SESSION_IPV_CORE_MESSAGE_ERROR);
+      }
+    }
+
+    const writeEventResult = await eventService.writeGenericEvent({
+      eventName: "DCMAW_ASYNC_CRI_5XXERROR",
+      sub: sessionAttributes?.subjectIdentifier,
+      sessionId,
+      govukSigninJourneyId: sessionAttributes?.govukSigninJourneyId,
+      getNowInMilliseconds: Date.now,
+      componentId: config.ISSUER,
+      ipAddress: undefined,
+      txmaAuditEncoded: undefined,
+      redirect_uri: sessionAttributes?.redirectUri,
+      suspected_fraud_signal: undefined,
+    });
+
+    if (writeEventResult.isError) {
+      logger.error(LogMessage.BIOMETRIC_SESSION_TXMA_EVENT_ERROR);
+    }
+
+    return;
+  }
+
+  const session = sessionResult.value;
+
+  if (session.finish !== "DONE") {
+    logger.info(LogMessage.BIOMETRIC_SESSION_NOT_READY, {
+      data: { finish: session.finish },
+    });
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.ts
user: login: tmulberry-gds
body: Added completion log
created_at: 2025-04-16T13:02:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -311,6 +311,45 @@ export class LogMessage implements LogAttributes {
     "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_INVALID_CONFIG",
     "One or more required environment variables were missing or invalid.",
   );
+
+  static readonly BIOMETRIC_SESSION_GET_FROM_READID_FAILURE = new LogMessage(
+    "MOBILE_ASYNC_BIOMETRIC_SESSION_GET_FROM_READID_FAILURE",
path: backend-api/src/functions/common/logging/LogMessage.ts
user: login: tmulberry-gds
body: Updated them all
created_at: 2025-04-16T14:30:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -38,19 +45,28 @@ export async function lambdaHandlerConstructor(
   if (validateSqsEventResult.isError) {
     return;
   }
+
   const sessionId = validateSqsEventResult.value;
 
   logger.appendKeys({ sessionId });
 
   const sessionRegistry = dependencies.getSessionRegistry(
     config.SESSION_TABLE_NAME,
   );
+
   const eventService = dependencies.getEventService(config.TXMA_SQS);
 
   const getSessionResult = await sessionRegistry.getSession(
     sessionId,
-    new GetSessionIssueBiometricCredential(),
+    new GetSessionBiometricTokenIssued(),
   );
+
+  if (getSessionResult.isError) {
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.ts
user: login: tmulberry-gds
body: Removed
created_at: 2025-04-16T14:30:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,136 @@
+import { logger } from "../../common/logging/logger";
+import { LogMessage } from "../../common/logging/LogMessage";
+import {
+  HttpError,
+  ISendHttpRequest,
+  sendHttpRequest as sendHttpRequestDefault,
+  SuccessfulHttpResponse,
+} from "../../adapters/http/sendHttpRequest";
+import { emptyFailure, Result, successResult } from "../../utils/result";
+
+export interface BiometricSession {
+  id: string;
+  finish: string;
+}
+
+let lastError: HttpError | null = null;
+
+export function getLastError(): HttpError | null {
+  return lastError;
+}
+
+export function isRetryableError(): boolean {
+  if (!lastError || lastError.statusCode === undefined) {
+    return false;
+  }
+
+  const retryableStatusCodes = [
+    429, 500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511,
+  ];
+
+  return retryableStatusCodes.includes(lastError.statusCode);
+}
+
+export type GetBiometricSession = (
+  baseUrl: string,
+  sessionId: string,
+  submitterKey: string,
+  sendHttpRequest?: ISendHttpRequest,
+) => Promise<Result<BiometricSession, void>>;
+
+export const getBiometricSession: GetBiometricSession = async (
+  baseUrl: string,
+  sessionId: string,
+  submitterKey: string,
+  sendHttpRequest: ISendHttpRequest = sendHttpRequestDefault,
+) => {
+  const httpRequest = {
+    url: `${baseUrl}/odata/v1/ODataServlet/Sessions('${sessionId}')`,
+    method: "GET" as const,
+    headers: {
+      "X-Innovalor-Authorization": submitterKey,
+      Accept: "application/json;odata.metadata=minimal",
+      "Content-Type": "application/json;odata.metadata=minimal",
+      "OData-MaxVersion": "4.0",
+      "OData-Version": "4.0",
+    },
+  };
+  const httpRequestLogData = {
+    ...httpRequest,
+    headers: {
+      ...httpRequest.headers,
+      "X-Innovalor-Authorization": "Secret value and cannot be logged",
+    },
+  };
+  const retryConfig = {
path: backend-api/src/functions/asyncIssueBiometricCredential/getBiometricSession/getBiometricSession.ts
user: login: tmulberry-gds
body: Rejigged this file a bit and fixed it
created_at: 2025-04-16T14:30:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,136 @@
+import { logger } from "../../common/logging/logger";
+import { LogMessage } from "../../common/logging/LogMessage";
+import {
+  HttpError,
+  ISendHttpRequest,
+  sendHttpRequest as sendHttpRequestDefault,
+  SuccessfulHttpResponse,
+} from "../../adapters/http/sendHttpRequest";
+import { emptyFailure, Result, successResult } from "../../utils/result";
+
+export interface BiometricSession {
+  id: string;
+  finish: string;
+}
+
+let lastError: HttpError | null = null;
+
+export function getLastError(): HttpError | null {
+  return lastError;
+}
+
+export function isRetryableError(): boolean {
+  if (!lastError || lastError.statusCode === undefined) {
+    return false;
+  }
+
+  const retryableStatusCodes = [
+    429, 500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511,
+  ];
+
+  return retryableStatusCodes.includes(lastError.statusCode);
+}
+
+export type GetBiometricSession = (
+  baseUrl: string,
+  sessionId: string,
+  submitterKey: string,
+  sendHttpRequest?: ISendHttpRequest,
+) => Promise<Result<BiometricSession, void>>;
+
+export const getBiometricSession: GetBiometricSession = async (
+  baseUrl: string,
+  sessionId: string,
+  submitterKey: string,
+  sendHttpRequest: ISendHttpRequest = sendHttpRequestDefault,
+) => {
+  const httpRequest = {
+    url: `${baseUrl}/odata/v1/ODataServlet/Sessions('${sessionId}')`,
+    method: "GET" as const,
+    headers: {
+      "X-Innovalor-Authorization": submitterKey,
+      Accept: "application/json;odata.metadata=minimal",
+      "Content-Type": "application/json;odata.metadata=minimal",
+      "OData-MaxVersion": "4.0",
+      "OData-Version": "4.0",
+    },
+  };
+  const httpRequestLogData = {
+    ...httpRequest,
+    headers: {
+      ...httpRequest.headers,
+      "X-Innovalor-Authorization": "Secret value and cannot be logged",
+    },
+  };
+  const retryConfig = {
+    retryableStatusCodes: [
+      429, 500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511,
+    ],
+    maxRetries: 3,
+    initialDelayMs: 50,
+    useExponentialBackoff: true,
+    useJitter: true,
+  };
+
+  logger.debug(LogMessage.BIOMETRIC_SESSION_GET_FROM_READID_ATTEMPT, {
+    data: {
+      httpRequest: httpRequestLogData,
+      sessionId,
+    },
+  });
+
+  const getBiometricSessionResult: Result<SuccessfulHttpResponse, HttpError> =
+    await sendHttpRequest(httpRequest, retryConfig);
+
+  if (getBiometricSessionResult.isError) {
+    lastError = getBiometricSessionResult.value;
path: backend-api/src/functions/asyncIssueBiometricCredential/getBiometricSession/getBiometricSession.ts
user: login: tmulberry-gds
body: See comment above
created_at: 2025-04-16T14:30:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,8 +1,16 @@
-type RetainMessageOnQueueMessages =
+type StaticRetainMessages =
path: backend-api/src/functions/asyncIssueBiometricCredential/RetainMessageOnQueue.ts
user: login: tmulberry-gds
body: I split them up because of the dynamic nature of the 'RetryableErrorMessages'. Happy to make them all into one though, what are your thoughts?
created_at: 2025-04-16T14:32:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,8 +1,16 @@
-type RetainMessageOnQueueMessages =
+type StaticRetainMessages =
   | "Invalid config"
   | "Failed to retrieve biometric viewer key"
   | "Unexpected failure retrieving session from database";
 
+type RetryableErrorMessage =
+  | `Retryable error (${number}) retrieving biometric session`
+  | `Biometric session not ready: ${string}`;
path: backend-api/src/functions/asyncIssueBiometricCredential/RetainMessageOnQueue.ts
user: login: tmulberry-gds
body: I've split it up further into a 'NonErrorMessage' type for clarity, what are your thoughts?
created_at: 2025-04-16T14:34:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -370,6 +417,153 @@ describe("Async Issue Biometric Credential", () => {
     });
   });
 
+  describe("When biometric session is not ready", () => {
+    beforeEach(() => {
+      dependencies.getBiometricSession = mockGetBiometricSessionNotReady;
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.test.ts
user: login: tmulberry-gds
body: Good point. I didn't realise we were going to adapt this session so soon, but I'm happy we are.

The tests in asyncIssueBiometricCredentialHandler.test.ts that check "When biometric session is not ready" and "When biometric session retrieval fails" are primarily testing the handler's behaviour when it receives different responses from the getBiometricSession function, rather than testing the getBiometricSession functionality itself.

I'm struggling to remember what the exact outcome of the dev time was, as in would we test lambda specific helpers inside the lambda itself, or extract it out. Happy to discuss this though
created_at: 2025-04-16T14:45:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -38,19 +45,28 @@ export async function lambdaHandlerConstructor(
   if (validateSqsEventResult.isError) {
     return;
   }
+
   const sessionId = validateSqsEventResult.value;
 
   logger.appendKeys({ sessionId });
 
   const sessionRegistry = dependencies.getSessionRegistry(
     config.SESSION_TABLE_NAME,
   );
+
   const eventService = dependencies.getEventService(config.TXMA_SQS);
 
   const getSessionResult = await sessionRegistry.getSession(
     sessionId,
-    new GetSessionIssueBiometricCredential(),
+    new GetSessionBiometricTokenIssued(),
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.ts
user: login: tmulberry-gds
body: Updated
created_at: 2025-04-16T14:46:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -74,7 +90,88 @@ export async function lambdaHandlerConstructor(
   if (viewerKeyResult.isError) {
     throw new RetainMessageOnQueue("Failed to retrieve biometric viewer key");
   }
+  const submitterKey = viewerKeyResult.value;
+
+  const sessionResult = await dependencies.getBiometricSession(
+    config.READID_BASE_URL,
+    sessionId,
+    submitterKey,
+  );
+
+  if (sessionResult.isError) {
+    const eventService = dependencies.getEventService(config.TXMA_SQS);
+
+    // Check if the error was retryable based on HTTP status code
+    if (isRetryableError()) {
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.ts
user: login: tmulberry-gds
body: This has since been removed and changed
created_at: 2025-04-16T14:46:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -74,7 +90,88 @@ export async function lambdaHandlerConstructor(
   if (viewerKeyResult.isError) {
     throw new RetainMessageOnQueue("Failed to retrieve biometric viewer key");
   }
+  const submitterKey = viewerKeyResult.value;
+
+  const sessionResult = await dependencies.getBiometricSession(
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.ts
user: login: tmulberry-gds
body: Updated naming
created_at: 2025-04-16T14:47:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -370,6 +417,153 @@ describe("Async Issue Biometric Credential", () => {
     });
   });
 
+  describe("When biometric session is not ready", () => {
+    beforeEach(() => {
+      dependencies.getBiometricSession = mockGetBiometricSessionNotReady;
+    });
+
+    it("Throws RetainMessageOnQueue with appropriate message", async () => {
+      await expect(
+        lambdaHandlerConstructor(dependencies, validSqsEvent, context),
+      ).rejects.toThrow(/Biometric session not ready: PROCESSING/);
+
+      expect(consoleInfoSpy).toHaveBeenCalledWithLogFields({
+        messageCode: "MOBILE_ASYNC_BIOMETRIC_SESSION_NOT_READY",
+        data: {
+          finish: "PROCESSING",
+        },
+      });
+    });
+  });
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.test.ts
user: login: tmulberry-gds
body: Ah yes good point. Updated them to match
created_at: 2025-04-16T14:56:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -7,17 +7,33 @@ import { lambdaHandlerConstructor } from "./asyncIssueBiometricCredentialHandler
 import { RetainMessageOnQueue } from "./RetainMessageOnQueue";
 import { IssueBiometricCredentialDependencies } from "./handlerDependencies";
 import {
-  mockBiometricSessionId,
-  mockInertEventService,
-  mockInertSessionRegistry,
   mockSessionId,
   mockSuccessfulEventService,
+  mockWriteGenericEventSuccessResult,
+  mockSuccessfulSendMessageToSqs,
+  mockInertSessionRegistry,
+  mockBiometricSessionId,
+  mockInertEventService,
   validBiometricSessionFinishedAttributes,
   validResultSentAttributes,
 } from "../testUtils/unitTestData";
 import { SessionRegistry } from "../common/session/SessionRegistry/SessionRegistry";
 import { emptyFailure, errorResult, successResult } from "../utils/result";
 import { GetSessionError } from "../common/session/SessionRegistry/types";
+import { BiometricSession } from "./getBiometricSession/getBiometricSession";
+
+jest.mock("./getBiometricSession/getBiometricSession", () => {
+  return {
+    ...jest.requireActual("./getBiometricSession/getBiometricSession"),
+    isRetryableError: jest.fn(),
+    getLastError: jest.fn(),
+  };
+});
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.test.ts
user: login: tmulberry-gds
body: Yeah this is just a mistake on my part lol. Removed
created_at: 2025-04-16T14:57:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,8 +1,16 @@
-type RetainMessageOnQueueMessages =
+type StaticRetainMessages =
path: backend-api/src/functions/asyncIssueBiometricCredential/RetainMessageOnQueue.ts
user: login: tmulberry-gds
body: On further thought my naming of these types is a bit all over the place too, so I can change that
created_at: 2025-04-16T15:05:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,8 +1,16 @@
-type RetainMessageOnQueueMessages =
+type StaticRetainMessages =
path: backend-api/src/functions/asyncIssueBiometricCredential/RetainMessageOnQueue.ts
user: login: chris-cooksley-gds
body: I'd vote to keep them all in a single type for simplicity - but that's pretty weakly held
created_at: 2025-04-16T15:31:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -370,6 +417,153 @@ describe("Async Issue Biometric Credential", () => {
     });
   });
 
+  describe("When biometric session is not ready", () => {
+    beforeEach(() => {
+      dependencies.getBiometricSession = mockGetBiometricSessionNotReady;
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.test.ts
user: login: chris-cooksley-gds
body: So the idea would be something like:

* update the handler to add `sendHttpRequest` into its dependencies, and pass this into `getBiometricSession.ts` so that we can provide a mock implementation of it in the handler unit tests
* remove the `getBiometricSession.test.ts` suite
* add tests to the handler test suite covering the same scenarios in the deleted suite, but calling the handler instead of calling `getBiometricSession` directly (e.g. we'd put the relevant mockSendHttpRequest into the handler dependencies, and then call the handler with a valid payload and assert on what was thrown/logged)
* if desired, split out those tests into their own file for readability - but crucially they're still just invoking the handler, and `getBiometricSession.ts` never gets called by any test.

But again this is non-blocking as we've only just decided on this pattern - given proximity to the end of sprint, it might be best to keep this as-is for now and look to follow this pattern next time.
created_at: 2025-04-16T15:37:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,136 @@
+import { logger } from "../../common/logging/logger";
+import { LogMessage } from "../../common/logging/LogMessage";
+import {
+  HttpError,
+  ISendHttpRequest,
+  sendHttpRequest as sendHttpRequestDefault,
+  SuccessfulHttpResponse,
+} from "../../adapters/http/sendHttpRequest";
+import { emptyFailure, Result, successResult } from "../../utils/result";
+
+export interface BiometricSession {
+  id: string;
+  finish: string;
+}
+
+let lastError: HttpError | null = null;
+
+export function getLastError(): HttpError | null {
+  return lastError;
+}
+
+export function isRetryableError(): boolean {
+  if (!lastError || lastError.statusCode === undefined) {
+    return false;
+  }
+
+  const retryableStatusCodes = [
+    429, 500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511,
+  ];
+
+  return retryableStatusCodes.includes(lastError.statusCode);
+}
+
+export type GetBiometricSession = (
+  baseUrl: string,
+  sessionId: string,
+  submitterKey: string,
+  sendHttpRequest?: ISendHttpRequest,
+) => Promise<Result<BiometricSession, void>>;
+
+export const getBiometricSession: GetBiometricSession = async (
+  baseUrl: string,
+  sessionId: string,
+  submitterKey: string,
+  sendHttpRequest: ISendHttpRequest = sendHttpRequestDefault,
+) => {
+  const httpRequest = {
+    url: `${baseUrl}/odata/v1/ODataServlet/Sessions('${sessionId}')`,
+    method: "GET" as const,
+    headers: {
+      "X-Innovalor-Authorization": submitterKey,
+      Accept: "application/json;odata.metadata=minimal",
+      "Content-Type": "application/json;odata.metadata=minimal",
+      "OData-MaxVersion": "4.0",
+      "OData-Version": "4.0",
+    },
+  };
+  const httpRequestLogData = {
+    ...httpRequest,
+    headers: {
+      ...httpRequest.headers,
+      "X-Innovalor-Authorization": "Secret value and cannot be logged",
+    },
+  };
+  const retryConfig = {
path: backend-api/src/functions/asyncIssueBiometricCredential/getBiometricSession/getBiometricSession.ts
user: login: chris-cooksley-gds
body: Unresolving this as this still doesn't satisfy the `RetryConfig` type - is it possible that a change hasn't pushed to github yet?
created_at: 2025-04-16T15:39:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,136 @@
+import { logger } from "../../common/logging/logger";
+import { LogMessage } from "../../common/logging/LogMessage";
+import {
+  HttpError,
+  ISendHttpRequest,
+  sendHttpRequest as sendHttpRequestDefault,
+  SuccessfulHttpResponse,
+} from "../../adapters/http/sendHttpRequest";
+import { emptyFailure, Result, successResult } from "../../utils/result";
+
+export interface BiometricSession {
+  id: string;
+  finish: string;
+}
+
+let lastError: HttpError | null = null;
+
+export function getLastError(): HttpError | null {
+  return lastError;
+}
+
+export function isRetryableError(): boolean {
+  if (!lastError || lastError.statusCode === undefined) {
+    return false;
+  }
+
+  const retryableStatusCodes = [
+    429, 500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511,
+  ];
+
+  return retryableStatusCodes.includes(lastError.statusCode);
+}
+
+export type GetBiometricSession = (
+  baseUrl: string,
+  sessionId: string,
+  submitterKey: string,
+  sendHttpRequest?: ISendHttpRequest,
+) => Promise<Result<BiometricSession, void>>;
+
+export const getBiometricSession: GetBiometricSession = async (
+  baseUrl: string,
+  sessionId: string,
+  submitterKey: string,
+  sendHttpRequest: ISendHttpRequest = sendHttpRequestDefault,
+) => {
+  const httpRequest = {
+    url: `${baseUrl}/odata/v1/ODataServlet/Sessions('${sessionId}')`,
+    method: "GET" as const,
+    headers: {
+      "X-Innovalor-Authorization": submitterKey,
+      Accept: "application/json;odata.metadata=minimal",
+      "Content-Type": "application/json;odata.metadata=minimal",
+      "OData-MaxVersion": "4.0",
+      "OData-Version": "4.0",
+    },
+  };
+  const httpRequestLogData = {
path: backend-api/src/functions/asyncIssueBiometricCredential/getBiometricSession/getBiometricSession.ts
user: login: chris-cooksley-gds
body: Unresolving this as it's still being logged - is it possible that a change hasn't pushed to github yet?
created_at: 2025-04-16T15:40:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,171 @@
+import { logger } from "../../common/logging/logger";
+import { LogMessage } from "../../common/logging/LogMessage";
+import {
+  HttpError,
+  ISendHttpRequest,
+  sendHttpRequest as sendHttpRequestDefault,
+  SuccessfulHttpResponse,
+} from "../../adapters/http/sendHttpRequest";
+import { Result, successResult, errorResult } from "../../utils/result";
+
+export interface BiometricSession {
+  id: string;
+  finish: string;
+}
+
+export interface BiometricSessionError {
+  statusCode?: number;
+  message: string;
+  isRetryable: boolean;
+}
+
+export type GetBiometricSession = (
+  baseUrl: string,
+  biometricSessionId: string,
+  viewerKey: string,
+  sendHttpRequest?: ISendHttpRequest,
+) => Promise<Result<BiometricSession, BiometricSessionError>>;
+
+export const getBiometricSession: GetBiometricSession = async (
+  baseUrl: string,
+  biometricSessionId: string,
+  viewerKey: string,
+  sendHttpRequest: ISendHttpRequest = sendHttpRequestDefault,
+) => {
+  const httpRequest = {
+    url: `${baseUrl}/odata/v1/ODataServlet/Sessions('${biometricSessionId}')`,
+    method: "GET" as const,
+    headers: {
+      "X-Innovalor-Authorization": viewerKey,
+      Accept: "application/json;odata.metadata=minimal",
+      "Content-Type": "application/json;odata.metadata=minimal",
+      "OData-MaxVersion": "4.0",
+      "OData-Version": "4.0",
+    },
+  };
+
+  const httpRequestLogData = {
+    ...httpRequest,
+    headers: {
+      ...httpRequest.headers,
+      "X-Innovalor-Authorization": "Secret value and cannot be logged",
+    },
+  };
+
+  const retryConfig = {
+    retryableStatusCodes: [
+      429, 500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511,
+    ],
+    maxRetries: 3,
+    initialDelayMs: 50,
+    useExponentialBackoff: true,
+    useJitter: true,
+  };
+
+  logger.debug(LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_ATTEMPT, {
+    data: {
+      httpRequest: httpRequestLogData,
+      biometricSessionId,
+    },
+  });
+
+  const getBiometricSessionResult: Result<SuccessfulHttpResponse, HttpError> =
+    await sendHttpRequest(httpRequest, retryConfig);
+
+  if (getBiometricSessionResult.isError) {
+    const error = getBiometricSessionResult.value;
+    const statusCode = error.statusCode ? Number(error.statusCode) : undefined;
+
+    const isRetryable =
+      !statusCode ||
+      statusCode === 429 ||
+      (statusCode >= 500 && statusCode < 600);
+
+    logger.error(
+      LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE,
+      {
+        data: {
+          error,
+          isRetryable,
+          httpRequest: httpRequestLogData,
+          biometricSessionId,
+        },
+      },
+    );
+
+    return errorResult({
+      statusCode,
+      message: "Failed to retrieve biometric session",
+      isRetryable,
+    });
+  }
+
+  const getBiometricSessionResponse = getBiometricSessionResult.value;
+
+  if (getBiometricSessionResponse?.body == null) {
+    logger.error(
+      LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE,
+      {
+        data: {
+          getBiometricSessionResponse,
+          httpRequest: httpRequestLogData,
+          biometricSessionId,
+        },
+      },
+    );
+
+    return errorResult({
+      message: "Empty response body from ReadID",
+      isRetryable: true,
+    });
+  }
+
+  let parsedBody;
+  try {
+    parsedBody = JSON.parse(getBiometricSessionResponse.body);
+  } catch (error) {
+    logger.error(
+      LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE,
+      {
+        data: {
+          error,
+          httpRequest: httpRequestLogData,
+          biometricSessionId,
+        },
+      },
+    );
+
+    return errorResult({
+      message: "Failed to parse response JSON",
+      isRetryable: true,
path: backend-api/src/functions/asyncIssueBiometricCredential/getBiometricSession/getBiometricSession.ts
user: login: chris-cooksley-gds
body: If ReadID sends a malformed response (including the malformed JSON here, or the empty response body above), I'm not sure that's something we should retry. But Tagging @sandymay-dd as I'm not confident on that point.
created_at: 2025-04-16T15:44:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -74,7 +79,91 @@ export async function lambdaHandlerConstructor(
   if (viewerKeyResult.isError) {
     throw new RetainMessageOnQueue("Failed to retrieve biometric viewer key");
   }
+  const submitterKey = viewerKeyResult.value;
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.ts
user: login: chris-cooksley-gds
body: ```suggestion
  const viewerKey = viewerKeyResult.value;
```
created_at: 2025-04-16T15:45:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -74,7 +79,91 @@ export async function lambdaHandlerConstructor(
   if (viewerKeyResult.isError) {
     throw new RetainMessageOnQueue("Failed to retrieve biometric viewer key");
   }
+  const submitterKey = viewerKeyResult.value;
+
+  const biometricSessionResult = await dependencies.getBiometricSession(
+    config.READID_BASE_URL,
+    sessionId,
+    submitterKey,
+  );
+
+  if (biometricSessionResult.isError) {
+    const eventService = dependencies.getEventService(config.TXMA_SQS);
+    const error: BiometricSessionError = biometricSessionResult.value;
+
+    // Check if the error was retryable based on error info
+    if (error.isRetryable) {
+      logger.error(LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_RETRYABLE_ERROR);
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.ts
user: login: chris-cooksley-gds
body: This log feels redundant, since we've already logged the error (and the value of `isRetryable`) in `getBiometricSession`
created_at: 2025-04-16T15:46:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -74,7 +79,91 @@ export async function lambdaHandlerConstructor(
   if (viewerKeyResult.isError) {
     throw new RetainMessageOnQueue("Failed to retrieve biometric viewer key");
   }
+  const submitterKey = viewerKeyResult.value;
+
+  const biometricSessionResult = await dependencies.getBiometricSession(
+    config.READID_BASE_URL,
+    sessionId,
+    submitterKey,
+  );
+
+  if (biometricSessionResult.isError) {
+    const eventService = dependencies.getEventService(config.TXMA_SQS);
+    const error: BiometricSessionError = biometricSessionResult.value;
+
+    // Check if the error was retryable based on error info
+    if (error.isRetryable) {
+      logger.error(LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_RETRYABLE_ERROR);
+      throw new RetainMessageOnQueue(
+        `Retryable error (${error.statusCode || "unknown"}) retrieving biometric session`,
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.ts
user: login: chris-cooksley-gds
body: ```suggestion
        `Retryable error (status code: ${error.statusCode || "N/A"}) retrieving biometric session`,
```

^ I think this gives a clearer result when there's no status code (i.e. we didn't get an HTTP response due to a network error or similar)
created_at: 2025-04-16T15:47:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,171 @@
+import { logger } from "../../common/logging/logger";
+import { LogMessage } from "../../common/logging/LogMessage";
+import {
+  HttpError,
+  ISendHttpRequest,
+  sendHttpRequest as sendHttpRequestDefault,
+  SuccessfulHttpResponse,
+} from "../../adapters/http/sendHttpRequest";
+import { Result, successResult, errorResult } from "../../utils/result";
+
+export interface BiometricSession {
+  id: string;
+  finish: string;
+}
+
+export interface BiometricSessionError {
+  statusCode?: number;
+  message: string;
+  isRetryable: boolean;
+}
+
+export type GetBiometricSession = (
+  baseUrl: string,
+  biometricSessionId: string,
+  viewerKey: string,
+  sendHttpRequest?: ISendHttpRequest,
+) => Promise<Result<BiometricSession, BiometricSessionError>>;
+
+export const getBiometricSession: GetBiometricSession = async (
+  baseUrl: string,
+  biometricSessionId: string,
+  viewerKey: string,
+  sendHttpRequest: ISendHttpRequest = sendHttpRequestDefault,
+) => {
+  const httpRequest = {
+    url: `${baseUrl}/odata/v1/ODataServlet/Sessions('${biometricSessionId}')`,
+    method: "GET" as const,
+    headers: {
+      "X-Innovalor-Authorization": viewerKey,
+      Accept: "application/json;odata.metadata=minimal",
+      "Content-Type": "application/json;odata.metadata=minimal",
+      "OData-MaxVersion": "4.0",
+      "OData-Version": "4.0",
+    },
+  };
+
+  const httpRequestLogData = {
+    ...httpRequest,
+    headers: {
+      ...httpRequest.headers,
+      "X-Innovalor-Authorization": "Secret value and cannot be logged",
+    },
+  };
+
+  const retryConfig = {
+    retryableStatusCodes: [
+      429, 500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511,
+    ],
+    maxRetries: 3,
+    initialDelayMs: 50,
+    useExponentialBackoff: true,
+    useJitter: true,
+  };
+
+  logger.debug(LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_ATTEMPT, {
+    data: {
+      httpRequest: httpRequestLogData,
+      biometricSessionId,
+    },
+  });
+
+  const getBiometricSessionResult: Result<SuccessfulHttpResponse, HttpError> =
+    await sendHttpRequest(httpRequest, retryConfig);
+
+  if (getBiometricSessionResult.isError) {
+    const error = getBiometricSessionResult.value;
+    const statusCode = error.statusCode ? Number(error.statusCode) : undefined;
+
+    const isRetryable =
+      !statusCode ||
+      statusCode === 429 ||
+      (statusCode >= 500 && statusCode < 600);
+
+    logger.error(
+      LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE,
+      {
+        data: {
+          error,
+          isRetryable,
+          httpRequest: httpRequestLogData,
+          biometricSessionId,
+        },
+      },
+    );
+
+    return errorResult({
+      statusCode,
+      message: "Failed to retrieve biometric session",
+      isRetryable,
+    });
+  }
+
+  const getBiometricSessionResponse = getBiometricSessionResult.value;
+
+  if (getBiometricSessionResponse?.body == null) {
+    logger.error(
+      LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE,
+      {
+        data: {
+          getBiometricSessionResponse,
+          httpRequest: httpRequestLogData,
+          biometricSessionId,
+        },
+      },
+    );
+
+    return errorResult({
+      message: "Empty response body from ReadID",
+      isRetryable: true,
+    });
+  }
+
+  let parsedBody;
+  try {
+    parsedBody = JSON.parse(getBiometricSessionResponse.body);
+  } catch (error) {
+    logger.error(
+      LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE,
+      {
+        data: {
+          error,
+          httpRequest: httpRequestLogData,
+          biometricSessionId,
+        },
+      },
+    );
+
+    return errorResult({
+      message: "Failed to parse response JSON",
+      isRetryable: true,
path: backend-api/src/functions/asyncIssueBiometricCredential/getBiometricSession/getBiometricSession.ts
user: login: sandymay-dd
body: Yep malformed response would not be retryable.
created_at: 2025-04-16T16:08:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,8 +1,16 @@
-type RetainMessageOnQueueMessages =
+type StaticRetainMessages =
path: backend-api/src/functions/asyncIssueBiometricCredential/RetainMessageOnQueue.ts
user: login: tmulberry-gds
body: Okay, changed them to a single type
created_at: 2025-04-17T08:19:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -74,7 +79,91 @@ export async function lambdaHandlerConstructor(
   if (viewerKeyResult.isError) {
     throw new RetainMessageOnQueue("Failed to retrieve biometric viewer key");
   }
+  const submitterKey = viewerKeyResult.value;
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.ts
user: login: tmulberry-gds
body: Changed to viewerKey
created_at: 2025-04-17T08:22:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -74,7 +79,91 @@ export async function lambdaHandlerConstructor(
   if (viewerKeyResult.isError) {
     throw new RetainMessageOnQueue("Failed to retrieve biometric viewer key");
   }
+  const submitterKey = viewerKeyResult.value;
+
+  const biometricSessionResult = await dependencies.getBiometricSession(
+    config.READID_BASE_URL,
+    sessionId,
+    submitterKey,
+  );
+
+  if (biometricSessionResult.isError) {
+    const eventService = dependencies.getEventService(config.TXMA_SQS);
+    const error: BiometricSessionError = biometricSessionResult.value;
+
+    // Check if the error was retryable based on error info
+    if (error.isRetryable) {
+      logger.error(LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_RETRYABLE_ERROR);
+      throw new RetainMessageOnQueue(
+        `Retryable error (${error.statusCode || "unknown"}) retrieving biometric session`,
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.ts
user: login: tmulberry-gds
body: Changed
created_at: 2025-04-17T08:24:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -74,7 +79,91 @@ export async function lambdaHandlerConstructor(
   if (viewerKeyResult.isError) {
     throw new RetainMessageOnQueue("Failed to retrieve biometric viewer key");
   }
+  const submitterKey = viewerKeyResult.value;
+
+  const biometricSessionResult = await dependencies.getBiometricSession(
+    config.READID_BASE_URL,
+    sessionId,
+    submitterKey,
+  );
+
+  if (biometricSessionResult.isError) {
+    const eventService = dependencies.getEventService(config.TXMA_SQS);
+    const error: BiometricSessionError = biometricSessionResult.value;
+
+    // Check if the error was retryable based on error info
+    if (error.isRetryable) {
+      logger.error(LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_RETRYABLE_ERROR);
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.ts
user: login: tmulberry-gds
body: Removed the log
created_at: 2025-04-17T08:24:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,136 @@
+import { logger } from "../../common/logging/logger";
+import { LogMessage } from "../../common/logging/LogMessage";
+import {
+  HttpError,
+  ISendHttpRequest,
+  sendHttpRequest as sendHttpRequestDefault,
+  SuccessfulHttpResponse,
+} from "../../adapters/http/sendHttpRequest";
+import { emptyFailure, Result, successResult } from "../../utils/result";
+
+export interface BiometricSession {
+  id: string;
+  finish: string;
+}
+
+let lastError: HttpError | null = null;
+
+export function getLastError(): HttpError | null {
+  return lastError;
+}
+
+export function isRetryableError(): boolean {
+  if (!lastError || lastError.statusCode === undefined) {
+    return false;
+  }
+
+  const retryableStatusCodes = [
+    429, 500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511,
+  ];
+
+  return retryableStatusCodes.includes(lastError.statusCode);
+}
+
+export type GetBiometricSession = (
+  baseUrl: string,
+  sessionId: string,
+  submitterKey: string,
+  sendHttpRequest?: ISendHttpRequest,
+) => Promise<Result<BiometricSession, void>>;
+
+export const getBiometricSession: GetBiometricSession = async (
+  baseUrl: string,
+  sessionId: string,
+  submitterKey: string,
+  sendHttpRequest: ISendHttpRequest = sendHttpRequestDefault,
+) => {
+  const httpRequest = {
+    url: `${baseUrl}/odata/v1/ODataServlet/Sessions('${sessionId}')`,
+    method: "GET" as const,
+    headers: {
+      "X-Innovalor-Authorization": submitterKey,
+      Accept: "application/json;odata.metadata=minimal",
+      "Content-Type": "application/json;odata.metadata=minimal",
+      "OData-MaxVersion": "4.0",
+      "OData-Version": "4.0",
+    },
+  };
+  const httpRequestLogData = {
+    ...httpRequest,
+    headers: {
+      ...httpRequest.headers,
+      "X-Innovalor-Authorization": "Secret value and cannot be logged",
+    },
+  };
+  const retryConfig = {
path: backend-api/src/functions/asyncIssueBiometricCredential/getBiometricSession/getBiometricSession.ts
user: login: tmulberry-gds
body: Yeah change wasn't pushed. got distracted by a failing test
created_at: 2025-04-17T08:25:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,157 @@
+import { logger } from "../../common/logging/logger";
+import { LogMessage } from "../../common/logging/LogMessage";
+import {
+  HttpError,
+  ISendHttpRequest,
+  RetryConfig,
+  sendHttpRequest as sendHttpRequestDefault,
+  SuccessfulHttpResponse,
+} from "../../adapters/http/sendHttpRequest";
+import { Result, successResult, errorResult } from "../../utils/result";
+
+export interface BiometricSession {
+  id: string;
+  finish: string;
+}
+
+export interface BiometricSessionError {
+  statusCode?: number;
+  message: string;
+  isRetryable: boolean;
+}
+
+export type GetBiometricSession = (
+  baseUrl: string,
+  biometricSessionId: string,
+  viewerKey: string,
+  sendHttpRequest?: ISendHttpRequest,
+) => Promise<Result<BiometricSession, BiometricSessionError>>;
+
+export const getBiometricSession: GetBiometricSession = async (
+  baseUrl: string,
+  biometricSessionId: string,
+  viewerKey: string,
+  sendHttpRequest: ISendHttpRequest = sendHttpRequestDefault,
+) => {
+  const httpRequest = {
+    url: `${baseUrl}/odata/v1/ODataServlet/Sessions('${biometricSessionId}')`,
+    method: "GET" as const,
+    headers: {
+      "X-Innovalor-Authorization": viewerKey,
+      Accept: "application/json;odata.metadata=minimal",
+      "Content-Type": "application/json;odata.metadata=minimal",
+      "OData-MaxVersion": "4.0",
+      "OData-Version": "4.0",
+    },
+  };
+
+  const retryConfig: RetryConfig = {
+    retryableStatusCodes: [
+      429, 500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511,
+    ],
+    maxAttempts: 3,
path: backend-api/src/functions/asyncIssueBiometricCredential/getBiometricSession/getBiometricSession.ts
user: login: chris-cooksley-gds
body: Nitpick: we can omit `maxAttempts` here as the default value is already `3`.
created_at: 2025-04-17T08:49:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -370,6 +417,153 @@ describe("Async Issue Biometric Credential", () => {
     });
   });
 
+  describe("When biometric session is not ready", () => {
+    beforeEach(() => {
+      dependencies.getBiometricSession = mockGetBiometricSessionNotReady;
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.test.ts
user: login: chris-cooksley-gds
body: Resolving this for now as it's not a priority given where we are in sprint.
created_at: 2025-04-17T08:50:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -74,7 +79,91 @@ export async function lambdaHandlerConstructor(
   if (viewerKeyResult.isError) {
     throw new RetainMessageOnQueue("Failed to retrieve biometric viewer key");
   }
+  const submitterKey = viewerKeyResult.value;
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.ts
user: login: chris-cooksley-gds
body: Needs pushing
created_at: 2025-04-17T08:51:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -74,7 +79,91 @@ export async function lambdaHandlerConstructor(
   if (viewerKeyResult.isError) {
     throw new RetainMessageOnQueue("Failed to retrieve biometric viewer key");
   }
+  const submitterKey = viewerKeyResult.value;
+
+  const biometricSessionResult = await dependencies.getBiometricSession(
+    config.READID_BASE_URL,
+    sessionId,
+    submitterKey,
+  );
+
+  if (biometricSessionResult.isError) {
+    const eventService = dependencies.getEventService(config.TXMA_SQS);
+    const error: BiometricSessionError = biometricSessionResult.value;
+
+    // Check if the error was retryable based on error info
+    if (error.isRetryable) {
+      logger.error(LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_RETRYABLE_ERROR);
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.ts
user: login: chris-cooksley-gds
body: Needs pushing
created_at: 2025-04-17T08:51:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -74,7 +79,91 @@ export async function lambdaHandlerConstructor(
   if (viewerKeyResult.isError) {
     throw new RetainMessageOnQueue("Failed to retrieve biometric viewer key");
   }
+  const submitterKey = viewerKeyResult.value;
+
+  const biometricSessionResult = await dependencies.getBiometricSession(
+    config.READID_BASE_URL,
+    sessionId,
+    submitterKey,
+  );
+
+  if (biometricSessionResult.isError) {
+    const eventService = dependencies.getEventService(config.TXMA_SQS);
+    const error: BiometricSessionError = biometricSessionResult.value;
+
+    // Check if the error was retryable based on error info
+    if (error.isRetryable) {
+      logger.error(LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_RETRYABLE_ERROR);
+      throw new RetainMessageOnQueue(
+        `Retryable error (${error.statusCode || "unknown"}) retrieving biometric session`,
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.ts
user: login: chris-cooksley-gds
body: Needs pushing
created_at: 2025-04-17T08:52:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,157 @@
+import { logger } from "../../common/logging/logger";
+import { LogMessage } from "../../common/logging/LogMessage";
+import {
+  HttpError,
+  ISendHttpRequest,
+  RetryConfig,
+  sendHttpRequest as sendHttpRequestDefault,
+  SuccessfulHttpResponse,
+} from "../../adapters/http/sendHttpRequest";
+import { Result, successResult, errorResult } from "../../utils/result";
+
+export interface BiometricSession {
+  id: string;
+  finish: string;
+}
+
+export interface BiometricSessionError {
+  statusCode?: number;
+  message: string;
+  isRetryable: boolean;
+}
+
+export type GetBiometricSession = (
+  baseUrl: string,
+  biometricSessionId: string,
+  viewerKey: string,
+  sendHttpRequest?: ISendHttpRequest,
+) => Promise<Result<BiometricSession, BiometricSessionError>>;
+
+export const getBiometricSession: GetBiometricSession = async (
+  baseUrl: string,
+  biometricSessionId: string,
+  viewerKey: string,
+  sendHttpRequest: ISendHttpRequest = sendHttpRequestDefault,
+) => {
+  const httpRequest = {
+    url: `${baseUrl}/odata/v1/ODataServlet/Sessions('${biometricSessionId}')`,
+    method: "GET" as const,
+    headers: {
+      "X-Innovalor-Authorization": viewerKey,
+      Accept: "application/json;odata.metadata=minimal",
+      "Content-Type": "application/json;odata.metadata=minimal",
+      "OData-MaxVersion": "4.0",
+      "OData-Version": "4.0",
+    },
+  };
+
+  const retryConfig: RetryConfig = {
+    retryableStatusCodes: [
+      429, 500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511,
+    ],
+    maxAttempts: 3,
+    delayInMillis: 50,
+  };
+
+  logger.debug(LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_ATTEMPT, {
+    data: {
+      biometricSessionId,
+    },
+  });
+
+  const getBiometricSessionResult: Result<SuccessfulHttpResponse, HttpError> =
+    await sendHttpRequest(httpRequest, retryConfig);
+
+  if (getBiometricSessionResult.isError) {
+    const error = getBiometricSessionResult.value;
+    const statusCode = error.statusCode ? Number(error.statusCode) : undefined;
+
+    const isRetryable =
+      !statusCode ||
+      statusCode === 429 ||
path: backend-api/src/functions/asyncIssueBiometricCredential/getBiometricSession/getBiometricSession.ts
user: login: chris-cooksley-gds
body: Suggestion: we could extract the `retryableStatusCodes` above into a const array, and then refer to that here to avoid duplication (e.g. `isRetryable = !statusCode || retryableStatusCodes.includes(statusCode)`)
created_at: 2025-04-17T08:54:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -74,7 +79,91 @@ export async function lambdaHandlerConstructor(
   if (viewerKeyResult.isError) {
     throw new RetainMessageOnQueue("Failed to retrieve biometric viewer key");
   }
+  const submitterKey = viewerKeyResult.value;
+
+  const biometricSessionResult = await dependencies.getBiometricSession(
+    config.READID_BASE_URL,
+    sessionId,
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.ts
user: login: chris-cooksley-gds
body: issue: this should be the `biometricSessionId` - you'll need to pull that out of the `sessionAttributes` above.

Once you've pulled it out - you might as well add it to the logger (`logger.appendKeys({biometricSessionId})`), and then you don't need to add it to the log data every time.
created_at: 2025-04-17T08:57:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,157 @@
+import { logger } from "../../common/logging/logger";
+import { LogMessage } from "../../common/logging/LogMessage";
+import {
+  HttpError,
+  ISendHttpRequest,
+  RetryConfig,
+  sendHttpRequest as sendHttpRequestDefault,
+  SuccessfulHttpResponse,
+} from "../../adapters/http/sendHttpRequest";
+import { Result, successResult, errorResult } from "../../utils/result";
+
+export interface BiometricSession {
+  id: string;
+  finish: string;
+}
+
+export interface BiometricSessionError {
path: backend-api/src/functions/asyncIssueBiometricCredential/getBiometricSession/getBiometricSession.ts
user: login: chris-cooksley-gds
body: ```suggestion
export interface GetBiometricSessionError {
```
created_at: 2025-04-17T08:57:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,157 @@
+import { logger } from "../../common/logging/logger";
+import { LogMessage } from "../../common/logging/LogMessage";
+import {
+  HttpError,
+  ISendHttpRequest,
+  RetryConfig,
+  sendHttpRequest as sendHttpRequestDefault,
+  SuccessfulHttpResponse,
+} from "../../adapters/http/sendHttpRequest";
+import { Result, successResult, errorResult } from "../../utils/result";
+
+export interface BiometricSession {
path: backend-api/src/functions/asyncIssueBiometricCredential/getBiometricSession/getBiometricSession.ts
user: login: chris-cooksley-gds
body: Thoughts:
* I don't think we do anything in this function to prove that there's an `id` field - nor do we depend on such a field - so it can probably be removed.
* A bit pedantic - and very much non-blocking - but I'd prefer that this not be called `BiometricSession`, mainly because (a) we haven't yet proved that it's a valid BiometricSession (that will happen in the package), and (b) we use that type name in the package to refer to a full-fledged BiometricSession with all the properties. Maybe we could go with something like `VendorResponse`? 
created_at: 2025-04-17T09:00:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -370,6 +420,119 @@ describe("Async Issue Biometric Credential", () => {
     });
   });
 
+  describe("When biometric session is not ready", () => {
+    beforeEach(async () => {
+      dependencies.getBiometricSession = mockGetBiometricSessionNotReady;
+      try {
+        await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
+      } catch (error: unknown) {
+        lambdaError = error;
+      }
+    });
+
+    it("Logs the appropriate message", () => {
+      expect(consoleInfoSpy).toHaveBeenCalledWithLogFields({
+        messageCode: "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_NOT_READY",
+        data: {
+          finish: "PROCESSING",
+        },
+      });
+    });
+
+    it("Throws RetainMessageOnQueue with appropriate message", () => {
+      expect(lambdaError).toBeInstanceOf(RetainMessageOnQueue);
+      expect((lambdaError as RetainMessageOnQueue).message).toMatch(
+        /Biometric session not ready: PROCESSING/,
+      );
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.test.ts
user: login: jmooney-dd
body: suggestion: you could do one assertion as below like we done elsewhere in this file

```suggestion
      expect(lambdaError).toStrictEqual(new RetainMessageOnQueue("Biometric session not ready: PROCESSING")
      );
```

Either way, can we make these test assertions be consistent in this file, either assert the way you have across all tests or update all these assertions you've added to be like the suggestion
created_at: 2025-04-17T09:03:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -370,6 +420,119 @@ describe("Async Issue Biometric Credential", () => {
     });
   });
 
+  describe("When biometric session is not ready", () => {
+    beforeEach(async () => {
+      dependencies.getBiometricSession = mockGetBiometricSessionNotReady;
+      try {
+        await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
+      } catch (error: unknown) {
+        lambdaError = error;
+      }
+    });
+
+    it("Logs the appropriate message", () => {
+      expect(consoleInfoSpy).toHaveBeenCalledWithLogFields({
+        messageCode: "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_NOT_READY",
+        data: {
+          finish: "PROCESSING",
+        },
+      });
+    });
+
+    it("Throws RetainMessageOnQueue with appropriate message", () => {
+      expect(lambdaError).toBeInstanceOf(RetainMessageOnQueue);
+      expect((lambdaError as RetainMessageOnQueue).message).toMatch(
+        /Biometric session not ready: PROCESSING/,
+      );
+    });
+  });
+
+  describe("When biometric session retrieval fails", () => {
+    describe("With retryable error", () => {
+      beforeEach(async () => {
+        dependencies.getBiometricSession =
+          mockGetBiometricSessionRetryableFailure;
+        try {
+          await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
+        } catch (error: unknown) {
+          lambdaError = error;
+        }
+      });
+
+      it("Logs retryable error", () => {
+        expect(consoleErrorSpy).toHaveBeenCalledWithLogFields({
+          messageCode:
+            "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_RETRYABLE_ERROR",
+        });
+      });
+
+      it("Throws RetainMessageOnQueue with appropriate message", () => {
+        expect(lambdaError).toBeInstanceOf(RetainMessageOnQueue);
+        expect((lambdaError as RetainMessageOnQueue).message).toMatch(
+          /Retryable error/,
+        );
+      });
+    });
+
+    describe("With non-retryable error", () => {
+      beforeEach(async () => {
+        dependencies.getBiometricSession =
+          mockGetBiometricSessionNonRetryableFailure;
+        await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
+      });
+
+      it("Logs non-retryable error", () => {
+        expect(consoleErrorSpy).toHaveBeenCalledWithLogFields({
+          messageCode:
+            "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_NON_RETRYABLE_ERROR",
+        });
+      });
+
+      it("Sends error to IPV Core", () => {
+        expect(mockSuccessfulSendMessageToSqs).toHaveBeenCalledWith(
+          "mockIpvcoreOutboundSqs",
+          {
+            sub: expect.any(String),
+            state: expect.any(String),
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.test.ts
user: login: jmooney-dd
body: suggestion: I think these come from the session attributes that we mock here, so can we not assert on them?
created_at: 2025-04-17T09:05:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,157 @@
+import { logger } from "../../common/logging/logger";
+import { LogMessage } from "../../common/logging/LogMessage";
+import {
+  HttpError,
+  ISendHttpRequest,
+  RetryConfig,
+  sendHttpRequest as sendHttpRequestDefault,
+  SuccessfulHttpResponse,
+} from "../../adapters/http/sendHttpRequest";
+import { Result, successResult, errorResult } from "../../utils/result";
+
+export interface BiometricSession {
+  id: string;
+  finish: string;
+}
+
+export interface BiometricSessionError {
+  statusCode?: number;
+  message: string;
path: backend-api/src/functions/asyncIssueBiometricCredential/getBiometricSession/getBiometricSession.ts
user: login: chris-cooksley-gds
body: I don't think this `message` field ever gets used - in which case it can be removed.

I'd also argue that we can remove `statusCode`. Since we've already logged it in this function, the only purpose of passing it back it to have it appear in the error message, which I don't think we need (I think it's enough to know we got a retryable failure, we can check the logs easily if we need more detail than that)
created_at: 2025-04-17T09:05:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -370,6 +420,119 @@ describe("Async Issue Biometric Credential", () => {
     });
   });
 
+  describe("When biometric session is not ready", () => {
+    beforeEach(async () => {
+      dependencies.getBiometricSession = mockGetBiometricSessionNotReady;
+      try {
+        await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
+      } catch (error: unknown) {
+        lambdaError = error;
+      }
+    });
+
+    it("Logs the appropriate message", () => {
+      expect(consoleInfoSpy).toHaveBeenCalledWithLogFields({
+        messageCode: "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_NOT_READY",
+        data: {
+          finish: "PROCESSING",
+        },
+      });
+    });
+
+    it("Throws RetainMessageOnQueue with appropriate message", () => {
+      expect(lambdaError).toBeInstanceOf(RetainMessageOnQueue);
+      expect((lambdaError as RetainMessageOnQueue).message).toMatch(
+        /Biometric session not ready: PROCESSING/,
+      );
+    });
+  });
+
+  describe("When biometric session retrieval fails", () => {
+    describe("With retryable error", () => {
+      beforeEach(async () => {
+        dependencies.getBiometricSession =
+          mockGetBiometricSessionRetryableFailure;
+        try {
+          await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
+        } catch (error: unknown) {
+          lambdaError = error;
+        }
+      });
+
+      it("Logs retryable error", () => {
+        expect(consoleErrorSpy).toHaveBeenCalledWithLogFields({
+          messageCode:
+            "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_RETRYABLE_ERROR",
+        });
+      });
+
+      it("Throws RetainMessageOnQueue with appropriate message", () => {
+        expect(lambdaError).toBeInstanceOf(RetainMessageOnQueue);
+        expect((lambdaError as RetainMessageOnQueue).message).toMatch(
+          /Retryable error/,
+        );
+      });
+    });
+
+    describe("With non-retryable error", () => {
+      beforeEach(async () => {
+        dependencies.getBiometricSession =
+          mockGetBiometricSessionNonRetryableFailure;
+        await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
+      });
+
+      it("Logs non-retryable error", () => {
+        expect(consoleErrorSpy).toHaveBeenCalledWithLogFields({
+          messageCode:
+            "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_NON_RETRYABLE_ERROR",
+        });
+      });
+
+      it("Sends error to IPV Core", () => {
+        expect(mockSuccessfulSendMessageToSqs).toHaveBeenCalledWith(
+          "mockIpvcoreOutboundSqs",
+          {
+            sub: expect.any(String),
+            state: expect.any(String),
+            error: "server_error",
+            error_description:
+              "Failed to retrieve biometric session from ReadID",
+          },
+        );
+      });
+
+      it("Sends event to TxMA", () => {
+        expect(mockWriteGenericEventSuccessResult).toHaveBeenCalledWith(
+          expect.objectContaining({
+            eventName: "DCMAW_ASYNC_CRI_5XXERROR",
+            componentId: "mockIssuer",
+          }),
+        );
+      });
+
+      describe("When sending error to IPV Core fails", () => {
+        beforeEach(async () => {
+          dependencies.sendMessageToSqs = jest
+            .fn()
+            .mockResolvedValue(emptyFailure());
+          dependencies.getBiometricSession =
+            mockGetBiometricSessionNonRetryableFailure;
+          await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
+        });
+
+        it("Logs IPV Core message error", () => {
+          expect(consoleErrorSpy).toHaveBeenCalledWithLogFields({
+            messageCode:
+              "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_IPV_CORE_MESSAGE_ERROR",
+          });
+        });
+
+        it("Still sends event to TxMA", () => {
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.test.ts
user: login: jmooney-dd
body: suggestion: should this test read more like the one a [few tests above](https://github.com/govuk-one-login/mobile-id-check-async/pull/586/files#diff-0943d2a2aba10106cb60b120be197df37495fcee2b70caa718c8485079ec7ca5R500)
created_at: 2025-04-17T09:06:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -38,19 +42,20 @@ export async function lambdaHandlerConstructor(
   if (validateSqsEventResult.isError) {
     return;
   }
-  const sessionId = validateSqsEventResult.value;
 
-  logger.appendKeys({ sessionId });
+  const sessionId = validateSqsEventResult.value;
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.ts
user: login: jmooney-dd
body: issue: it looks like we've removed the step to append the sessionId to the logger. Can we add this back in?
created_at: 2025-04-17T09:08:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -74,7 +79,91 @@ export async function lambdaHandlerConstructor(
   if (viewerKeyResult.isError) {
     throw new RetainMessageOnQueue("Failed to retrieve biometric viewer key");
   }
+  const submitterKey = viewerKeyResult.value;
+
+  const biometricSessionResult = await dependencies.getBiometricSession(
+    config.READID_BASE_URL,
+    sessionId,
+    submitterKey,
+  );
+
+  if (biometricSessionResult.isError) {
+    const eventService = dependencies.getEventService(config.TXMA_SQS);
+    const error: BiometricSessionError = biometricSessionResult.value;
+
+    // Check if the error was retryable based on error info
+    if (error.isRetryable) {
+      logger.error(LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_RETRYABLE_ERROR);
+      throw new RetainMessageOnQueue(
+        `Retryable error (${error.statusCode || "unknown"}) retrieving biometric session`,
+      );
+    }
+
+    // Non-retryable error - send error to IPV Core
+    logger.error(LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_NON_RETRYABLE_ERROR, {
+      data: {
+        sessionId,
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.ts
user: login: jmooney-dd
body: suggestion: remove `sessionId` from data

`sessionId` will already be in log at this point as we append it earlier in the lambda
created_at: 2025-04-17T09:09:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,157 @@
+import { logger } from "../../common/logging/logger";
+import { LogMessage } from "../../common/logging/LogMessage";
+import {
+  HttpError,
+  ISendHttpRequest,
+  RetryConfig,
+  sendHttpRequest as sendHttpRequestDefault,
+  SuccessfulHttpResponse,
+} from "../../adapters/http/sendHttpRequest";
+import { Result, successResult, errorResult } from "../../utils/result";
+
+export interface BiometricSession {
+  id: string;
+  finish: string;
+}
+
+export interface BiometricSessionError {
+  statusCode?: number;
+  message: string;
+  isRetryable: boolean;
+}
+
+export type GetBiometricSession = (
+  baseUrl: string,
+  biometricSessionId: string,
+  viewerKey: string,
+  sendHttpRequest?: ISendHttpRequest,
+) => Promise<Result<BiometricSession, BiometricSessionError>>;
+
+export const getBiometricSession: GetBiometricSession = async (
+  baseUrl: string,
+  biometricSessionId: string,
+  viewerKey: string,
+  sendHttpRequest: ISendHttpRequest = sendHttpRequestDefault,
+) => {
+  const httpRequest = {
+    url: `${baseUrl}/odata/v1/ODataServlet/Sessions('${biometricSessionId}')`,
+    method: "GET" as const,
+    headers: {
+      "X-Innovalor-Authorization": viewerKey,
+      Accept: "application/json;odata.metadata=minimal",
+      "Content-Type": "application/json;odata.metadata=minimal",
+      "OData-MaxVersion": "4.0",
+      "OData-Version": "4.0",
+    },
+  };
+
+  const retryConfig: RetryConfig = {
+    retryableStatusCodes: [
+      429, 500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511,
+    ],
+    maxAttempts: 3,
+    delayInMillis: 50,
+  };
+
+  logger.debug(LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_ATTEMPT, {
+    data: {
+      biometricSessionId,
+    },
+  });
+
+  const getBiometricSessionResult: Result<SuccessfulHttpResponse, HttpError> =
+    await sendHttpRequest(httpRequest, retryConfig);
+
+  if (getBiometricSessionResult.isError) {
+    const error = getBiometricSessionResult.value;
+    const statusCode = error.statusCode ? Number(error.statusCode) : undefined;
+
+    const isRetryable =
+      !statusCode ||
+      statusCode === 429 ||
+      (statusCode >= 500 && statusCode < 600);
+
+    logger.error(
+      LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE,
+      {
+        data: {
+          error,
+          isRetryable,
+          biometricSessionId,
+        },
+      },
+    );
+
+    return errorResult({
+      statusCode,
+      message: "Failed to retrieve biometric session",
+      isRetryable,
+    });
+  }
+
+  const getBiometricSessionResponse = getBiometricSessionResult.value;
+
+  if (getBiometricSessionResponse?.body == null) {
+    logger.error(
+      LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE,
+      {
+        data: {
+          getBiometricSessionResponse,
+          biometricSessionId,
+        },
+      },
+    );
+
+    return errorResult({
+      message: "Empty response body from ReadID",
+      isRetryable: true,
+    });
+  }
+
+  let parsedBody;
+  try {
+    parsedBody = JSON.parse(getBiometricSessionResponse.body);
+  } catch (error) {
+    logger.error(
+      LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE,
+      {
+        data: {
+          error,
+          biometricSessionId,
+        },
+      },
+    );
+
+    return errorResult({
+      message: "Failed to parse response JSON",
+      isRetryable: true,
+    });
+  }
+
+  // Check if the response has the expected structure
+  if (!parsedBody || typeof parsedBody.finish !== "string") {
+    logger.error(
+      LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE,
+      {
+        data: {
+          parsedBody,
path: backend-api/src/functions/asyncIssueBiometricCredential/getBiometricSession/getBiometricSession.ts
user: login: chris-cooksley-gds
body: I'm nervous about logging `parsedBody` here. A _valid_ biometric session would contain a lot of PII, and if we somehow got a malformed one through, there's no guarantee that the PII wouldn't be there (e.g. if they renamed the `finish` attribute unexpectedly but everything else was still there, we'd be logging a lot of PII here). Can we instead just indicate in the log data that there wasn't a valid `finish` property?
created_at: 2025-04-17T09:09:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,157 @@
+import { logger } from "../../common/logging/logger";
+import { LogMessage } from "../../common/logging/LogMessage";
+import {
+  HttpError,
+  ISendHttpRequest,
+  RetryConfig,
+  sendHttpRequest as sendHttpRequestDefault,
+  SuccessfulHttpResponse,
+} from "../../adapters/http/sendHttpRequest";
+import { Result, successResult, errorResult } from "../../utils/result";
+
+export interface BiometricSession {
+  id: string;
+  finish: string;
+}
+
+export interface BiometricSessionError {
+  statusCode?: number;
+  message: string;
+  isRetryable: boolean;
+}
+
+export type GetBiometricSession = (
+  baseUrl: string,
+  biometricSessionId: string,
+  viewerKey: string,
+  sendHttpRequest?: ISendHttpRequest,
+) => Promise<Result<BiometricSession, BiometricSessionError>>;
+
+export const getBiometricSession: GetBiometricSession = async (
+  baseUrl: string,
+  biometricSessionId: string,
+  viewerKey: string,
+  sendHttpRequest: ISendHttpRequest = sendHttpRequestDefault,
+) => {
+  const httpRequest = {
+    url: `${baseUrl}/odata/v1/ODataServlet/Sessions('${biometricSessionId}')`,
+    method: "GET" as const,
+    headers: {
+      "X-Innovalor-Authorization": viewerKey,
+      Accept: "application/json;odata.metadata=minimal",
+      "Content-Type": "application/json;odata.metadata=minimal",
+      "OData-MaxVersion": "4.0",
+      "OData-Version": "4.0",
+    },
+  };
+
+  const retryConfig: RetryConfig = {
+    retryableStatusCodes: [
+      429, 500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511,
+    ],
+    maxAttempts: 3,
+    delayInMillis: 50,
+  };
+
+  logger.debug(LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_ATTEMPT, {
+    data: {
+      biometricSessionId,
+    },
+  });
+
+  const getBiometricSessionResult: Result<SuccessfulHttpResponse, HttpError> =
+    await sendHttpRequest(httpRequest, retryConfig);
+
+  if (getBiometricSessionResult.isError) {
path: backend-api/src/functions/asyncIssueBiometricCredential/getBiometricSession/getBiometricSession.ts
user: login: chris-cooksley-gds
body: Nitpick: I think a helper function for this error handling would aid readability.
created_at: 2025-04-17T09:10:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -74,7 +79,91 @@ export async function lambdaHandlerConstructor(
   if (viewerKeyResult.isError) {
     throw new RetainMessageOnQueue("Failed to retrieve biometric viewer key");
   }
+  const submitterKey = viewerKeyResult.value;
+
+  const biometricSessionResult = await dependencies.getBiometricSession(
+    config.READID_BASE_URL,
+    sessionId,
+    submitterKey,
+  );
+
+  if (biometricSessionResult.isError) {
+    const eventService = dependencies.getEventService(config.TXMA_SQS);
+    const error: BiometricSessionError = biometricSessionResult.value;
+
+    // Check if the error was retryable based on error info
+    if (error.isRetryable) {
+      logger.error(LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_RETRYABLE_ERROR);
+      throw new RetainMessageOnQueue(
+        `Retryable error (${error.statusCode || "unknown"}) retrieving biometric session`,
+      );
+    }
+
+    // Non-retryable error - send error to IPV Core
+    logger.error(LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_NON_RETRYABLE_ERROR, {
+      data: {
+        sessionId,
+        error,
+      },
+    });
+
+    if (
+      sessionAttributes?.subjectIdentifier &&
+      sessionAttributes?.clientState
+    ) {
+      const ipvCoreOutboundMessage: IssueBiometricCredentialMessage = {
+        sub: sessionAttributes.subjectIdentifier,
+        state: sessionAttributes.clientState,
+        error: "server_error",
+        error_description: "Failed to retrieve biometric session from ReadID",
+      };
+
+      const sendMessageToIPVCoreOutboundQueueResult =
+        await dependencies.sendMessageToSqs(
+          config.IPVCORE_OUTBOUND_SQS,
+          ipvCoreOutboundMessage,
+        );
+
+      if (sendMessageToIPVCoreOutboundQueueResult.isError) {
+        logger.error(
+          LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_IPV_CORE_MESSAGE_ERROR,
+        );
+      }
+    }
+
+    const writeEventResult = await eventService.writeGenericEvent({
+      eventName: "DCMAW_ASYNC_CRI_5XXERROR",
+      sub: sessionAttributes?.subjectIdentifier,
+      sessionId,
+      govukSigninJourneyId: sessionAttributes?.govukSigninJourneyId,
+      getNowInMilliseconds: Date.now,
+      componentId: config.ISSUER,
+      ipAddress: undefined,
+      txmaAuditEncoded: undefined,
+      redirect_uri: sessionAttributes?.redirectUri,
+      suspected_fraud_signal: undefined,
+    });
+
+    if (writeEventResult.isError) {
+      logger.error(LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_TXMA_EVENT_ERROR);
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.ts
user: login: jmooney-dd
body: issue: this is inconsistent with the log message we usually use when a txma event fails to write. Can we update this to `ERROR_WRITING_AUDIT_EVENT`
created_at: 2025-04-17T09:11:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -38,19 +45,28 @@ export async function lambdaHandlerConstructor(
   if (validateSqsEventResult.isError) {
     return;
   }
+
   const sessionId = validateSqsEventResult.value;
 
   logger.appendKeys({ sessionId });
 
   const sessionRegistry = dependencies.getSessionRegistry(
     config.SESSION_TABLE_NAME,
   );
+
   const eventService = dependencies.getEventService(config.TXMA_SQS);
 
   const getSessionResult = await sessionRegistry.getSession(
     sessionId,
-    new GetSessionIssueBiometricCredential(),
+    new GetSessionBiometricTokenIssued(),
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.ts
user: login: chris-cooksley-gds
body: This still needs updating. Out of interest, why did it change in the first place?
created_at: 2025-04-17T09:11:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,171 @@
+import { logger } from "../../common/logging/logger";
+import { LogMessage } from "../../common/logging/LogMessage";
+import {
+  HttpError,
+  ISendHttpRequest,
+  sendHttpRequest as sendHttpRequestDefault,
+  SuccessfulHttpResponse,
+} from "../../adapters/http/sendHttpRequest";
+import { Result, successResult, errorResult } from "../../utils/result";
+
+export interface BiometricSession {
+  id: string;
+  finish: string;
+}
+
+export interface BiometricSessionError {
+  statusCode?: number;
+  message: string;
+  isRetryable: boolean;
+}
+
+export type GetBiometricSession = (
+  baseUrl: string,
+  biometricSessionId: string,
+  viewerKey: string,
+  sendHttpRequest?: ISendHttpRequest,
+) => Promise<Result<BiometricSession, BiometricSessionError>>;
+
+export const getBiometricSession: GetBiometricSession = async (
+  baseUrl: string,
+  biometricSessionId: string,
+  viewerKey: string,
+  sendHttpRequest: ISendHttpRequest = sendHttpRequestDefault,
+) => {
+  const httpRequest = {
+    url: `${baseUrl}/odata/v1/ODataServlet/Sessions('${biometricSessionId}')`,
+    method: "GET" as const,
+    headers: {
+      "X-Innovalor-Authorization": viewerKey,
+      Accept: "application/json;odata.metadata=minimal",
+      "Content-Type": "application/json;odata.metadata=minimal",
+      "OData-MaxVersion": "4.0",
+      "OData-Version": "4.0",
+    },
+  };
+
+  const httpRequestLogData = {
+    ...httpRequest,
+    headers: {
+      ...httpRequest.headers,
+      "X-Innovalor-Authorization": "Secret value and cannot be logged",
+    },
+  };
+
+  const retryConfig = {
+    retryableStatusCodes: [
+      429, 500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511,
+    ],
+    maxRetries: 3,
+    initialDelayMs: 50,
+    useExponentialBackoff: true,
+    useJitter: true,
+  };
+
+  logger.debug(LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_ATTEMPT, {
+    data: {
+      httpRequest: httpRequestLogData,
+      biometricSessionId,
+    },
+  });
+
+  const getBiometricSessionResult: Result<SuccessfulHttpResponse, HttpError> =
+    await sendHttpRequest(httpRequest, retryConfig);
+
+  if (getBiometricSessionResult.isError) {
+    const error = getBiometricSessionResult.value;
+    const statusCode = error.statusCode ? Number(error.statusCode) : undefined;
+
+    const isRetryable =
+      !statusCode ||
+      statusCode === 429 ||
+      (statusCode >= 500 && statusCode < 600);
+
+    logger.error(
+      LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE,
+      {
+        data: {
+          error,
+          isRetryable,
+          httpRequest: httpRequestLogData,
+          biometricSessionId,
+        },
+      },
+    );
+
+    return errorResult({
+      statusCode,
+      message: "Failed to retrieve biometric session",
+      isRetryable,
+    });
+  }
+
+  const getBiometricSessionResponse = getBiometricSessionResult.value;
+
+  if (getBiometricSessionResponse?.body == null) {
+    logger.error(
+      LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE,
+      {
+        data: {
+          getBiometricSessionResponse,
+          httpRequest: httpRequestLogData,
+          biometricSessionId,
+        },
+      },
+    );
+
+    return errorResult({
+      message: "Empty response body from ReadID",
+      isRetryable: true,
+    });
+  }
+
+  let parsedBody;
+  try {
+    parsedBody = JSON.parse(getBiometricSessionResponse.body);
+  } catch (error) {
+    logger.error(
+      LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE,
+      {
+        data: {
+          error,
+          httpRequest: httpRequestLogData,
+          biometricSessionId,
+        },
+      },
+    );
+
+    return errorResult({
+      message: "Failed to parse response JSON",
+      isRetryable: true,
path: backend-api/src/functions/asyncIssueBiometricCredential/getBiometricSession/getBiometricSession.ts
user: login: tmulberry-gds
body: Changed isRetryable from true to false
created_at: 2025-04-17T09:11:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -311,6 +311,50 @@ export class LogMessage implements LogAttributes {
     "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_INVALID_CONFIG",
     "One or more required environment variables were missing or invalid.",
   );
+
+  static readonly ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE =
+    new LogMessage(
+      "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE",
+      "Failed to retrieve biometric session from ReadID",
+    );
+  static readonly ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_SUCCESS =
+    new LogMessage(
+      "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_SUCCESS",
+      "Successfully retrieved biometric session from ReadID",
+    );
+  static readonly ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_ATTEMPT =
+    new LogMessage(
+      "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_ATTEMPT",
+      "Retrieving biometric session from ReadID",
+    );
+  static readonly ISSUE_BIOMETRIC_CREDENTIAL_NOT_READY = new LogMessage(
+    "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_NOT_READY",
+    "Biometric session not ready (finish status not DONE)",
+  );
+
+  static readonly ISSUE_BIOMETRIC_CREDENTIAL_IPV_CORE_MESSAGE_ERROR =
+    new LogMessage(
+      "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_IPV_CORE_MESSAGE_ERROR",
+      "Error sending message to IPV Core",
+    );
+  static readonly ISSUE_BIOMETRIC_CREDENTIAL_TXMA_EVENT_ERROR = new LogMessage(
+    "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_TXMA_EVENT_ERROR",
+    "Error sending event to TxMA",
+  );
path: backend-api/src/functions/common/logging/LogMessage.ts
user: login: jmooney-dd
body: issue: we don't need this log message as we have a shared message for this `MOBILE_ASYNC_ERROR_WRITING_AUDIT_EVENT`
created_at: 2025-04-17T09:11:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,157 @@
+import { logger } from "../../common/logging/logger";
+import { LogMessage } from "../../common/logging/LogMessage";
+import {
+  HttpError,
+  ISendHttpRequest,
+  RetryConfig,
+  sendHttpRequest as sendHttpRequestDefault,
+  SuccessfulHttpResponse,
+} from "../../adapters/http/sendHttpRequest";
+import { Result, successResult, errorResult } from "../../utils/result";
+
+export interface BiometricSession {
+  id: string;
+  finish: string;
+}
+
+export interface BiometricSessionError {
+  statusCode?: number;
+  message: string;
+  isRetryable: boolean;
+}
+
+export type GetBiometricSession = (
+  baseUrl: string,
+  biometricSessionId: string,
+  viewerKey: string,
+  sendHttpRequest?: ISendHttpRequest,
+) => Promise<Result<BiometricSession, BiometricSessionError>>;
+
+export const getBiometricSession: GetBiometricSession = async (
+  baseUrl: string,
+  biometricSessionId: string,
+  viewerKey: string,
+  sendHttpRequest: ISendHttpRequest = sendHttpRequestDefault,
+) => {
+  const httpRequest = {
+    url: `${baseUrl}/odata/v1/ODataServlet/Sessions('${biometricSessionId}')`,
+    method: "GET" as const,
+    headers: {
+      "X-Innovalor-Authorization": viewerKey,
+      Accept: "application/json;odata.metadata=minimal",
+      "Content-Type": "application/json;odata.metadata=minimal",
+      "OData-MaxVersion": "4.0",
+      "OData-Version": "4.0",
+    },
+  };
+
+  const retryConfig: RetryConfig = {
+    retryableStatusCodes: [
+      429, 500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511,
+    ],
+    maxAttempts: 3,
path: backend-api/src/functions/asyncIssueBiometricCredential/getBiometricSession/getBiometricSession.ts
user: login: tmulberry-gds
body: Yeah, removed
created_at: 2025-04-17T09:12:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -311,6 +311,50 @@ export class LogMessage implements LogAttributes {
     "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_INVALID_CONFIG",
     "One or more required environment variables were missing or invalid.",
   );
+
+  static readonly ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE =
+    new LogMessage(
+      "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE",
+      "Failed to retrieve biometric session from ReadID",
+    );
+  static readonly ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_SUCCESS =
+    new LogMessage(
+      "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_SUCCESS",
+      "Successfully retrieved biometric session from ReadID",
+    );
+  static readonly ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_ATTEMPT =
+    new LogMessage(
+      "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_ATTEMPT",
+      "Retrieving biometric session from ReadID",
+    );
+  static readonly ISSUE_BIOMETRIC_CREDENTIAL_NOT_READY = new LogMessage(
+    "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_NOT_READY",
+    "Biometric session not ready (finish status not DONE)",
+  );
+
+  static readonly ISSUE_BIOMETRIC_CREDENTIAL_IPV_CORE_MESSAGE_ERROR =
+    new LogMessage(
+      "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_IPV_CORE_MESSAGE_ERROR",
+      "Error sending message to IPV Core",
+    );
+  static readonly ISSUE_BIOMETRIC_CREDENTIAL_TXMA_EVENT_ERROR = new LogMessage(
+    "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_TXMA_EVENT_ERROR",
+    "Error sending event to TxMA",
+  );
+  static readonly SESSION_NOT_FOUND = new LogMessage(
+    "MOBILE_ASYNC_SESSION_NOT_FOUND",
+    "Session not found in session registry",
+  );
path: backend-api/src/functions/common/logging/LogMessage.ts
user: login: jmooney-dd
body: issue: there is no issue biometric credential prefix here so this should either:
- Have a prefix
- Not have a prefix and be moved to the shared section of log messages
created_at: 2025-04-17T09:12:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -74,7 +79,90 @@ export async function lambdaHandlerConstructor(
   if (viewerKeyResult.isError) {
     throw new RetainMessageOnQueue("Failed to retrieve biometric viewer key");
   }
+  const viewerKey = viewerKeyResult.value;
+
+  const biometricSessionResult = await dependencies.getBiometricSession(
+    config.READID_BASE_URL,
+    sessionId,
+    viewerKey,
+  );
+
+  if (biometricSessionResult.isError) {
+    const eventService = dependencies.getEventService(config.TXMA_SQS);
+    const error: BiometricSessionError = biometricSessionResult.value;
+
+    // Check if the error was retryable based on error info
+    if (error.isRetryable) {
+      throw new RetainMessageOnQueue(
+        `Retryable error (status code: ${error.statusCode || "N/A"}) retrieving biometric session`,
+      );
+    }
+
+    // Non-retryable error - send error to IPV Core
+    logger.error(LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_NON_RETRYABLE_ERROR, {
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.ts
user: login: chris-cooksley-gds
body: We've already logged this failure in `getBiometricSession`, along with the value of `isRetryable`, so this log can be removed.
created_at: 2025-04-17T09:14:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,157 @@
+import { logger } from "../../common/logging/logger";
+import { LogMessage } from "../../common/logging/LogMessage";
+import {
+  HttpError,
+  ISendHttpRequest,
+  RetryConfig,
+  sendHttpRequest as sendHttpRequestDefault,
+  SuccessfulHttpResponse,
+} from "../../adapters/http/sendHttpRequest";
+import { Result, successResult, errorResult } from "../../utils/result";
+
+export interface BiometricSession {
+  id: string;
+  finish: string;
+}
+
+export interface BiometricSessionError {
+  statusCode?: number;
+  message: string;
+  isRetryable: boolean;
+}
+
+export type GetBiometricSession = (
+  baseUrl: string,
+  biometricSessionId: string,
+  viewerKey: string,
+  sendHttpRequest?: ISendHttpRequest,
+) => Promise<Result<BiometricSession, BiometricSessionError>>;
+
+export const getBiometricSession: GetBiometricSession = async (
+  baseUrl: string,
+  biometricSessionId: string,
+  viewerKey: string,
+  sendHttpRequest: ISendHttpRequest = sendHttpRequestDefault,
+) => {
+  const httpRequest = {
+    url: `${baseUrl}/odata/v1/ODataServlet/Sessions('${biometricSessionId}')`,
+    method: "GET" as const,
+    headers: {
+      "X-Innovalor-Authorization": viewerKey,
+      Accept: "application/json;odata.metadata=minimal",
+      "Content-Type": "application/json;odata.metadata=minimal",
+      "OData-MaxVersion": "4.0",
+      "OData-Version": "4.0",
+    },
+  };
+
+  const retryConfig: RetryConfig = {
+    retryableStatusCodes: [
+      429, 500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511,
+    ],
+    maxAttempts: 3,
+    delayInMillis: 50,
+  };
+
+  logger.debug(LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_ATTEMPT, {
+    data: {
+      biometricSessionId,
+    },
+  });
+
+  const getBiometricSessionResult: Result<SuccessfulHttpResponse, HttpError> =
+    await sendHttpRequest(httpRequest, retryConfig);
+
+  if (getBiometricSessionResult.isError) {
+    const error = getBiometricSessionResult.value;
+    const statusCode = error.statusCode ? Number(error.statusCode) : undefined;
+
+    const isRetryable =
+      !statusCode ||
+      statusCode === 429 ||
path: backend-api/src/functions/asyncIssueBiometricCredential/getBiometricSession/getBiometricSession.ts
user: login: tmulberry-gds
body: Yes, extracted out
created_at: 2025-04-17T09:15:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -74,7 +79,91 @@ export async function lambdaHandlerConstructor(
   if (viewerKeyResult.isError) {
     throw new RetainMessageOnQueue("Failed to retrieve biometric viewer key");
   }
+  const submitterKey = viewerKeyResult.value;
+
+  const biometricSessionResult = await dependencies.getBiometricSession(
+    config.READID_BASE_URL,
+    sessionId,
+    submitterKey,
+  );
+
+  if (biometricSessionResult.isError) {
+    const eventService = dependencies.getEventService(config.TXMA_SQS);
+    const error: BiometricSessionError = biometricSessionResult.value;
+
+    // Check if the error was retryable based on error info
+    if (error.isRetryable) {
+      logger.error(LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_RETRYABLE_ERROR);
+      throw new RetainMessageOnQueue(
+        `Retryable error (${error.statusCode || "unknown"}) retrieving biometric session`,
+      );
+    }
+
+    // Non-retryable error - send error to IPV Core
+    logger.error(LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_NON_RETRYABLE_ERROR, {
+      data: {
+        sessionId,
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.ts
user: login: jmooney-dd
body: Potentially you meant biometricSessionId here, if so I think this should be appended when we get it as well
created_at: 2025-04-17T09:15:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -74,7 +79,90 @@ export async function lambdaHandlerConstructor(
   if (viewerKeyResult.isError) {
     throw new RetainMessageOnQueue("Failed to retrieve biometric viewer key");
   }
+  const viewerKey = viewerKeyResult.value;
+
+  const biometricSessionResult = await dependencies.getBiometricSession(
+    config.READID_BASE_URL,
+    sessionId,
+    viewerKey,
+  );
+
+  if (biometricSessionResult.isError) {
+    const eventService = dependencies.getEventService(config.TXMA_SQS);
+    const error: BiometricSessionError = biometricSessionResult.value;
+
+    // Check if the error was retryable based on error info
+    if (error.isRetryable) {
+      throw new RetainMessageOnQueue(
+        `Retryable error (status code: ${error.statusCode || "N/A"}) retrieving biometric session`,
+      );
+    }
+
+    // Non-retryable error - send error to IPV Core
+    logger.error(LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_NON_RETRYABLE_ERROR, {
+      data: {
+        sessionId,
+        error,
+      },
+    });
+
+    if (
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.ts
user: login: chris-cooksley-gds
body: This conditional is unnecessary - `sessionAttributes` has already been validated and will always have these fields
created_at: 2025-04-17T09:15:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -74,7 +79,90 @@ export async function lambdaHandlerConstructor(
   if (viewerKeyResult.isError) {
     throw new RetainMessageOnQueue("Failed to retrieve biometric viewer key");
   }
+  const viewerKey = viewerKeyResult.value;
+
+  const biometricSessionResult = await dependencies.getBiometricSession(
+    config.READID_BASE_URL,
+    sessionId,
+    viewerKey,
+  );
+
+  if (biometricSessionResult.isError) {
+    const eventService = dependencies.getEventService(config.TXMA_SQS);
+    const error: BiometricSessionError = biometricSessionResult.value;
+
+    // Check if the error was retryable based on error info
+    if (error.isRetryable) {
+      throw new RetainMessageOnQueue(
+        `Retryable error (status code: ${error.statusCode || "N/A"}) retrieving biometric session`,
+      );
+    }
+
+    // Non-retryable error - send error to IPV Core
+    logger.error(LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_NON_RETRYABLE_ERROR, {
+      data: {
+        sessionId,
+        error,
+      },
+    });
+
+    if (
+      sessionAttributes?.subjectIdentifier &&
+      sessionAttributes?.clientState
+    ) {
+      const ipvCoreOutboundMessage: IssueBiometricCredentialMessage = {
+        sub: sessionAttributes.subjectIdentifier,
+        state: sessionAttributes.clientState,
+        error: "server_error",
+        error_description: "Failed to retrieve biometric session from ReadID",
+      };
+
+      const sendMessageToIPVCoreOutboundQueueResult =
+        await dependencies.sendMessageToSqs(
+          config.IPVCORE_OUTBOUND_SQS,
+          ipvCoreOutboundMessage,
+        );
+
+      if (sendMessageToIPVCoreOutboundQueueResult.isError) {
+        logger.error(
+          LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_IPV_CORE_MESSAGE_ERROR,
+        );
+      }
+    }
+
+    const writeEventResult = await eventService.writeGenericEvent({
+      eventName: "DCMAW_ASYNC_CRI_5XXERROR",
+      sub: sessionAttributes?.subjectIdentifier,
+      sessionId,
+      govukSigninJourneyId: sessionAttributes?.govukSigninJourneyId,
+      getNowInMilliseconds: Date.now,
+      componentId: config.ISSUER,
+      ipAddress: undefined,
+      txmaAuditEncoded: undefined,
+      redirect_uri: sessionAttributes?.redirectUri,
+      suspected_fraud_signal: undefined,
+    });
+
+    if (writeEventResult.isError) {
+      logger.error(LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_TXMA_EVENT_ERROR);
+    }
+
+    return;
+  }
+
+  const session = biometricSessionResult.value;
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.ts
user: login: chris-cooksley-gds
body: ```suggestion
  const biometricSession = biometricSessionResult.value;
```
created_at: 2025-04-17T09:16:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -74,7 +79,90 @@ export async function lambdaHandlerConstructor(
   if (viewerKeyResult.isError) {
     throw new RetainMessageOnQueue("Failed to retrieve biometric viewer key");
   }
+  const viewerKey = viewerKeyResult.value;
+
+  const biometricSessionResult = await dependencies.getBiometricSession(
+    config.READID_BASE_URL,
+    sessionId,
+    viewerKey,
+  );
+
+  if (biometricSessionResult.isError) {
+    const eventService = dependencies.getEventService(config.TXMA_SQS);
+    const error: BiometricSessionError = biometricSessionResult.value;
+
+    // Check if the error was retryable based on error info
+    if (error.isRetryable) {
+      throw new RetainMessageOnQueue(
+        `Retryable error (status code: ${error.statusCode || "N/A"}) retrieving biometric session`,
+      );
+    }
+
+    // Non-retryable error - send error to IPV Core
+    logger.error(LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_NON_RETRYABLE_ERROR, {
+      data: {
+        sessionId,
+        error,
+      },
+    });
+
+    if (
+      sessionAttributes?.subjectIdentifier &&
+      sessionAttributes?.clientState
+    ) {
+      const ipvCoreOutboundMessage: IssueBiometricCredentialMessage = {
+        sub: sessionAttributes.subjectIdentifier,
+        state: sessionAttributes.clientState,
+        error: "server_error",
+        error_description: "Failed to retrieve biometric session from ReadID",
+      };
+
+      const sendMessageToIPVCoreOutboundQueueResult =
+        await dependencies.sendMessageToSqs(
+          config.IPVCORE_OUTBOUND_SQS,
+          ipvCoreOutboundMessage,
+        );
+
+      if (sendMessageToIPVCoreOutboundQueueResult.isError) {
+        logger.error(
+          LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_IPV_CORE_MESSAGE_ERROR,
+        );
+      }
+    }
+
+    const writeEventResult = await eventService.writeGenericEvent({
+      eventName: "DCMAW_ASYNC_CRI_5XXERROR",
+      sub: sessionAttributes?.subjectIdentifier,
+      sessionId,
+      govukSigninJourneyId: sessionAttributes?.govukSigninJourneyId,
+      getNowInMilliseconds: Date.now,
+      componentId: config.ISSUER,
+      ipAddress: undefined,
+      txmaAuditEncoded: undefined,
+      redirect_uri: sessionAttributes?.redirectUri,
+      suspected_fraud_signal: undefined,
+    });
+
+    if (writeEventResult.isError) {
+      logger.error(LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_TXMA_EVENT_ERROR);
+    }
+
+    return;
+  }
+
+  const session = biometricSessionResult.value;
+
+  if (session.finish !== "DONE") {
+    logger.info(LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_NOT_READY, {
+      data: { finish: session.finish },
+    });
+    logger.info(LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_COMPLETED);
+    throw new RetainMessageOnQueue(
+      `Biometric session not ready: ${session.finish}`,
+    );
+  }
 
+  // Session is ready, continue processing
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.ts
user: login: chris-cooksley-gds
body: ```suggestion
  // Biometric session is ready, continue processing
```
created_at: 2025-04-17T09:17:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -74,7 +79,90 @@ export async function lambdaHandlerConstructor(
   if (viewerKeyResult.isError) {
     throw new RetainMessageOnQueue("Failed to retrieve biometric viewer key");
   }
+  const viewerKey = viewerKeyResult.value;
+
+  const biometricSessionResult = await dependencies.getBiometricSession(
+    config.READID_BASE_URL,
+    sessionId,
+    viewerKey,
+  );
+
+  if (biometricSessionResult.isError) {
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.ts
user: login: chris-cooksley-gds
body: For readability, it would be good to extract this error handling into one or more helper functions within this file.
created_at: 2025-04-17T09:18:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -74,7 +79,90 @@ export async function lambdaHandlerConstructor(
   if (viewerKeyResult.isError) {
     throw new RetainMessageOnQueue("Failed to retrieve biometric viewer key");
   }
+  const viewerKey = viewerKeyResult.value;
+
+  const biometricSessionResult = await dependencies.getBiometricSession(
+    config.READID_BASE_URL,
+    sessionId,
+    viewerKey,
+  );
+
+  if (biometricSessionResult.isError) {
+    const eventService = dependencies.getEventService(config.TXMA_SQS);
+    const error: BiometricSessionError = biometricSessionResult.value;
+
+    // Check if the error was retryable based on error info
+    if (error.isRetryable) {
+      throw new RetainMessageOnQueue(
+        `Retryable error (status code: ${error.statusCode || "N/A"}) retrieving biometric session`,
+      );
+    }
+
+    // Non-retryable error - send error to IPV Core
+    logger.error(LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_NON_RETRYABLE_ERROR, {
+      data: {
+        sessionId,
+        error,
+      },
+    });
+
+    if (
+      sessionAttributes?.subjectIdentifier &&
+      sessionAttributes?.clientState
+    ) {
+      const ipvCoreOutboundMessage: IssueBiometricCredentialMessage = {
+        sub: sessionAttributes.subjectIdentifier,
+        state: sessionAttributes.clientState,
+        error: "server_error",
+        error_description: "Failed to retrieve biometric session from ReadID",
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.ts
user: login: chris-cooksley-gds
body: Can we keep the error_description to `Internal server error` or similar? I don't think we need to be telling IPV core about the internals of our system.
created_at: 2025-04-17T09:19:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -74,7 +79,91 @@ export async function lambdaHandlerConstructor(
   if (viewerKeyResult.isError) {
     throw new RetainMessageOnQueue("Failed to retrieve biometric viewer key");
   }
+  const submitterKey = viewerKeyResult.value;
+
+  const biometricSessionResult = await dependencies.getBiometricSession(
+    config.READID_BASE_URL,
+    sessionId,
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.ts
user: login: tmulberry-gds
body: the sessionAttributes doesn't have biometricSessionId, only sessionId, should I use that?
created_at: 2025-04-17T09:19:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,157 @@
+import { logger } from "../../common/logging/logger";
+import { LogMessage } from "../../common/logging/LogMessage";
+import {
+  HttpError,
+  ISendHttpRequest,
+  RetryConfig,
+  sendHttpRequest as sendHttpRequestDefault,
+  SuccessfulHttpResponse,
+} from "../../adapters/http/sendHttpRequest";
+import { Result, successResult, errorResult } from "../../utils/result";
+
+export interface BiometricSession {
+  id: string;
+  finish: string;
+}
+
+export interface BiometricSessionError {
path: backend-api/src/functions/asyncIssueBiometricCredential/getBiometricSession/getBiometricSession.ts
user: login: tmulberry-gds
body: Updated
created_at: 2025-04-17T09:19:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -74,7 +79,90 @@ export async function lambdaHandlerConstructor(
   if (viewerKeyResult.isError) {
     throw new RetainMessageOnQueue("Failed to retrieve biometric viewer key");
   }
+  const viewerKey = viewerKeyResult.value;
+
+  const biometricSessionResult = await dependencies.getBiometricSession(
+    config.READID_BASE_URL,
+    sessionId,
+    viewerKey,
+  );
+
+  if (biometricSessionResult.isError) {
+    const eventService = dependencies.getEventService(config.TXMA_SQS);
+    const error: BiometricSessionError = biometricSessionResult.value;
+
+    // Check if the error was retryable based on error info
+    if (error.isRetryable) {
+      throw new RetainMessageOnQueue(
+        `Retryable error (status code: ${error.statusCode || "N/A"}) retrieving biometric session`,
+      );
+    }
+
+    // Non-retryable error - send error to IPV Core
+    logger.error(LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_NON_RETRYABLE_ERROR, {
+      data: {
+        sessionId,
+        error,
+      },
+    });
+
+    if (
+      sessionAttributes?.subjectIdentifier &&
+      sessionAttributes?.clientState
+    ) {
+      const ipvCoreOutboundMessage: IssueBiometricCredentialMessage = {
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.ts
user: login: chris-cooksley-gds
body: Thought: writing an error message to the outbound queue is something we'll be doing in quite a few places. It would be a good candidate to extract into a helper function.
created_at: 2025-04-17T09:20:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -10,4 +10,14 @@ export interface AbortSessionMessage {
   error_description: string;
 }
 
-export type SQSMessageBody = VendorProcessingMessage | AbortSessionMessage;
+export interface IssueBiometricCredentialMessage {
path: backend-api/src/functions/adapters/aws/sqs/types.ts
user: login: chris-cooksley-gds
body: This is identical to `AbortSessionMessage` above. Rather than create this new interface, can we update the name of the old one to be more generic (e.g. `OutboundQueueErrorMessage`?
created_at: 2025-04-17T09:21:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,155 @@
+import { logger } from "../../common/logging/logger";
+import { LogMessage } from "../../common/logging/LogMessage";
+import {
+  HttpError,
+  ISendHttpRequest,
+  RetryConfig,
+  sendHttpRequest as sendHttpRequestDefault,
+  SuccessfulHttpResponse,
+} from "../../adapters/http/sendHttpRequest";
+import { Result, successResult, errorResult } from "../../utils/result";
+
+export interface BiometricSession {
+  id: string;
+  finish: string;
+}
+
+export interface GetBiometricSessionError {
+  statusCode?: number;
+  message: string;
+  isRetryable: boolean;
+}
+
+export type GetBiometricSession = (
+  baseUrl: string,
+  biometricSessionId: string,
+  viewerKey: string,
+  sendHttpRequest?: ISendHttpRequest,
+) => Promise<Result<BiometricSession, GetBiometricSessionError>>;
+
+export const getBiometricSession: GetBiometricSession = async (
+  baseUrl: string,
+  biometricSessionId: string,
+  viewerKey: string,
+  sendHttpRequest: ISendHttpRequest = sendHttpRequestDefault,
+) => {
+  const httpRequest = {
+    url: `${baseUrl}/odata/v1/ODataServlet/Sessions('${biometricSessionId}')`,
+    method: "GET" as const,
+    headers: {
+      "X-Innovalor-Authorization": viewerKey,
+      Accept: "application/json;odata.metadata=minimal",
+      "Content-Type": "application/json;odata.metadata=minimal",
+      "OData-MaxVersion": "4.0",
+      "OData-Version": "4.0",
+    },
+  };
+
+  const retryConfig: RetryConfig = {
+    retryableStatusCodes: [
+      429, 500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511,
+    ],
+    delayInMillis: 50,
+  };
+
+  logger.debug(LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_ATTEMPT, {
+    data: {
+      biometricSessionId,
+    },
+  });
+
+  const getBiometricSessionResult: Result<SuccessfulHttpResponse, HttpError> =
+    await sendHttpRequest(httpRequest, retryConfig);
+
+  if (getBiometricSessionResult.isError) {
+    const error = getBiometricSessionResult.value;
+    const statusCode = error.statusCode ? Number(error.statusCode) : undefined;
+    const retryableStatusCodes = retryConfig.retryableStatusCodes;
+
+    const isRetryable =
+      !statusCode || (retryableStatusCodes?.includes(statusCode) ?? false);
+
+    logger.error(
+      LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE,
+      {
+        data: {
+          error,
+          isRetryable,
+          biometricSessionId,
+        },
+      },
+    );
+
+    return errorResult({
+      statusCode,
+      message: "Failed to retrieve biometric session",
+      isRetryable,
+    });
+  }
+
+  const getBiometricSessionResponse = getBiometricSessionResult.value;
+
+  if (getBiometricSessionResponse?.body == null) {
+    logger.error(
+      LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE,
+      {
+        data: {
+          getBiometricSessionResponse,
+          biometricSessionId,
+        },
+      },
+    );
+
+    return errorResult({
+      message: "Empty response body from ReadID",
+      isRetryable: true,
path: backend-api/src/functions/asyncIssueBiometricCredential/getBiometricSession/getBiometricSession.ts
user: login: chris-cooksley-gds
body: See other comment @tmulberry-gds - can we also change this to `false`
created_at: 2025-04-17T09:23:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,155 @@
+import { logger } from "../../common/logging/logger";
+import { LogMessage } from "../../common/logging/LogMessage";
+import {
+  HttpError,
+  ISendHttpRequest,
+  RetryConfig,
+  sendHttpRequest as sendHttpRequestDefault,
+  SuccessfulHttpResponse,
+} from "../../adapters/http/sendHttpRequest";
+import { Result, successResult, errorResult } from "../../utils/result";
+
+export interface BiometricSession {
+  id: string;
+  finish: string;
+}
+
+export interface GetBiometricSessionError {
+  statusCode?: number;
+  message: string;
+  isRetryable: boolean;
+}
+
+export type GetBiometricSession = (
+  baseUrl: string,
+  biometricSessionId: string,
+  viewerKey: string,
+  sendHttpRequest?: ISendHttpRequest,
+) => Promise<Result<BiometricSession, GetBiometricSessionError>>;
+
+export const getBiometricSession: GetBiometricSession = async (
+  baseUrl: string,
+  biometricSessionId: string,
+  viewerKey: string,
+  sendHttpRequest: ISendHttpRequest = sendHttpRequestDefault,
+) => {
+  const httpRequest = {
+    url: `${baseUrl}/odata/v1/ODataServlet/Sessions('${biometricSessionId}')`,
+    method: "GET" as const,
+    headers: {
+      "X-Innovalor-Authorization": viewerKey,
+      Accept: "application/json;odata.metadata=minimal",
+      "Content-Type": "application/json;odata.metadata=minimal",
+      "OData-MaxVersion": "4.0",
+      "OData-Version": "4.0",
+    },
+  };
+
+  const retryConfig: RetryConfig = {
+    retryableStatusCodes: [
+      429, 500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511,
+    ],
+    delayInMillis: 50,
+  };
+
+  logger.debug(LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_ATTEMPT, {
+    data: {
+      biometricSessionId,
+    },
+  });
+
+  const getBiometricSessionResult: Result<SuccessfulHttpResponse, HttpError> =
+    await sendHttpRequest(httpRequest, retryConfig);
+
+  if (getBiometricSessionResult.isError) {
+    const error = getBiometricSessionResult.value;
+    const statusCode = error.statusCode ? Number(error.statusCode) : undefined;
+    const retryableStatusCodes = retryConfig.retryableStatusCodes;
+
+    const isRetryable =
+      !statusCode || (retryableStatusCodes?.includes(statusCode) ?? false);
path: backend-api/src/functions/asyncIssueBiometricCredential/getBiometricSession/getBiometricSession.ts
user: login: chris-cooksley-gds
body: ```suggestion
      !statusCode || (retryableStatusCodes.includes(statusCode));
```

^ (we know that `retryableStatusCodes` is an array, and `includes` will always return a boolean)
created_at: 2025-04-17T09:25:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -8,8 +8,10 @@ import { logger } from "../common/logging/logger";
 import { LogMessage } from "../common/logging/LogMessage";
 
 const REQUIRED_ENVIRONMENT_VARIABLES = [
+  "READID_BASE_URL",
path: backend-api/src/functions/asyncIssueBiometricCredential/issueBiometricCredentialConfig.ts
user: login: jmooney-dd
body: issue: I don't think this lambda has access to this envVar as it's not global
created_at: 2025-04-17T09:26:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -370,6 +420,119 @@ describe("Async Issue Biometric Credential", () => {
     });
   });
 
+  describe("When biometric session is not ready", () => {
+    beforeEach(async () => {
+      dependencies.getBiometricSession = mockGetBiometricSessionNotReady;
+      try {
+        await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
+      } catch (error: unknown) {
+        lambdaError = error;
+      }
+    });
+
+    it("Logs the appropriate message", () => {
+      expect(consoleInfoSpy).toHaveBeenCalledWithLogFields({
+        messageCode: "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_NOT_READY",
+        data: {
+          finish: "PROCESSING",
+        },
+      });
+    });
+
+    it("Throws RetainMessageOnQueue with appropriate message", () => {
+      expect(lambdaError).toBeInstanceOf(RetainMessageOnQueue);
+      expect((lambdaError as RetainMessageOnQueue).message).toMatch(
+        /Biometric session not ready: PROCESSING/,
+      );
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.test.ts
user: login: tmulberry-gds
body: Comitted
created_at: 2025-04-17T09:28:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -74,7 +79,91 @@ export async function lambdaHandlerConstructor(
   if (viewerKeyResult.isError) {
     throw new RetainMessageOnQueue("Failed to retrieve biometric viewer key");
   }
+  const submitterKey = viewerKeyResult.value;
+
+  const biometricSessionResult = await dependencies.getBiometricSession(
+    config.READID_BASE_URL,
+    sessionId,
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.ts
user: login: chris-cooksley-gds
body: Ah - so from a type perspective that field is only present in `BiometricSessionFinishedAttributes`, whereas what we have at this point is typed as the generic `SessionAttributes`. However, we know at this point in the code that we have a `BiometricSessionFinishedAttributes, because (a) the chosen get operation only allows sessions in `ASYNC_RESULT_SENT` or `ASYNC_BIOMETRIC_SESSION_FINISHED`, and (b) we've already ruled out the former on line 69.

Therefore, can we do something like `const { biometricSessionId } = (sessionAttributes as BiometricSessionFinishedAttributes)`
created_at: 2025-04-17T09:28:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -370,6 +420,119 @@ describe("Async Issue Biometric Credential", () => {
     });
   });
 
+  describe("When biometric session is not ready", () => {
+    beforeEach(async () => {
+      dependencies.getBiometricSession = mockGetBiometricSessionNotReady;
+      try {
+        await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
+      } catch (error: unknown) {
+        lambdaError = error;
+      }
+    });
+
+    it("Logs the appropriate message", () => {
+      expect(consoleInfoSpy).toHaveBeenCalledWithLogFields({
+        messageCode: "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_NOT_READY",
+        data: {
+          finish: "PROCESSING",
+        },
+      });
+    });
+
+    it("Throws RetainMessageOnQueue with appropriate message", () => {
+      expect(lambdaError).toBeInstanceOf(RetainMessageOnQueue);
+      expect((lambdaError as RetainMessageOnQueue).message).toMatch(
+        /Biometric session not ready: PROCESSING/,
+      );
+    });
+  });
+
+  describe("When biometric session retrieval fails", () => {
+    describe("With retryable error", () => {
+      beforeEach(async () => {
+        dependencies.getBiometricSession =
+          mockGetBiometricSessionRetryableFailure;
+        try {
+          await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
+        } catch (error: unknown) {
+          lambdaError = error;
+        }
+      });
+
+      it("Logs retryable error", () => {
+        expect(consoleErrorSpy).toHaveBeenCalledWithLogFields({
+          messageCode:
+            "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_RETRYABLE_ERROR",
+        });
+      });
+
+      it("Throws RetainMessageOnQueue with appropriate message", () => {
+        expect(lambdaError).toBeInstanceOf(RetainMessageOnQueue);
+        expect((lambdaError as RetainMessageOnQueue).message).toMatch(
+          /Retryable error/,
+        );
+      });
+    });
+
+    describe("With non-retryable error", () => {
+      beforeEach(async () => {
+        dependencies.getBiometricSession =
+          mockGetBiometricSessionNonRetryableFailure;
+        await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
+      });
+
+      it("Logs non-retryable error", () => {
+        expect(consoleErrorSpy).toHaveBeenCalledWithLogFields({
+          messageCode:
+            "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_NON_RETRYABLE_ERROR",
+        });
+      });
+
+      it("Sends error to IPV Core", () => {
+        expect(mockSuccessfulSendMessageToSqs).toHaveBeenCalledWith(
+          "mockIpvcoreOutboundSqs",
+          {
+            sub: expect.any(String),
+            state: expect.any(String),
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.test.ts
user: login: tmulberry-gds
body: Updated to use values
created_at: 2025-04-17T09:30:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,157 @@
+import { logger } from "../../common/logging/logger";
+import { LogMessage } from "../../common/logging/LogMessage";
+import {
+  HttpError,
+  ISendHttpRequest,
+  RetryConfig,
+  sendHttpRequest as sendHttpRequestDefault,
+  SuccessfulHttpResponse,
+} from "../../adapters/http/sendHttpRequest";
+import { Result, successResult, errorResult } from "../../utils/result";
+
+export interface BiometricSession {
+  id: string;
+  finish: string;
+}
+
+export interface BiometricSessionError {
+  statusCode?: number;
+  message: string;
path: backend-api/src/functions/asyncIssueBiometricCredential/getBiometricSession/getBiometricSession.ts
user: login: tmulberry-gds
body: I removed both of them
created_at: 2025-04-17T09:35:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -38,19 +42,20 @@ export async function lambdaHandlerConstructor(
   if (validateSqsEventResult.isError) {
     return;
   }
-  const sessionId = validateSqsEventResult.value;
 
-  logger.appendKeys({ sessionId });
+  const sessionId = validateSqsEventResult.value;
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.ts
user: login: tmulberry-gds
body: Whoops. Added it back in
created_at: 2025-04-17T09:36:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -74,7 +79,91 @@ export async function lambdaHandlerConstructor(
   if (viewerKeyResult.isError) {
     throw new RetainMessageOnQueue("Failed to retrieve biometric viewer key");
   }
+  const submitterKey = viewerKeyResult.value;
+
+  const biometricSessionResult = await dependencies.getBiometricSession(
+    config.READID_BASE_URL,
+    sessionId,
+    submitterKey,
+  );
+
+  if (biometricSessionResult.isError) {
+    const eventService = dependencies.getEventService(config.TXMA_SQS);
+    const error: BiometricSessionError = biometricSessionResult.value;
+
+    // Check if the error was retryable based on error info
+    if (error.isRetryable) {
+      logger.error(LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_RETRYABLE_ERROR);
+      throw new RetainMessageOnQueue(
+        `Retryable error (${error.statusCode || "unknown"}) retrieving biometric session`,
+      );
+    }
+
+    // Non-retryable error - send error to IPV Core
+    logger.error(LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_NON_RETRYABLE_ERROR, {
+      data: {
+        sessionId,
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.ts
user: login: tmulberry-gds
body: Removed
created_at: 2025-04-17T09:37:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,157 @@
+import { logger } from "../../common/logging/logger";
+import { LogMessage } from "../../common/logging/LogMessage";
+import {
+  HttpError,
+  ISendHttpRequest,
+  RetryConfig,
+  sendHttpRequest as sendHttpRequestDefault,
+  SuccessfulHttpResponse,
+} from "../../adapters/http/sendHttpRequest";
+import { Result, successResult, errorResult } from "../../utils/result";
+
+export interface BiometricSession {
+  id: string;
+  finish: string;
+}
+
+export interface BiometricSessionError {
+  statusCode?: number;
+  message: string;
+  isRetryable: boolean;
+}
+
+export type GetBiometricSession = (
+  baseUrl: string,
+  biometricSessionId: string,
+  viewerKey: string,
+  sendHttpRequest?: ISendHttpRequest,
+) => Promise<Result<BiometricSession, BiometricSessionError>>;
+
+export const getBiometricSession: GetBiometricSession = async (
+  baseUrl: string,
+  biometricSessionId: string,
+  viewerKey: string,
+  sendHttpRequest: ISendHttpRequest = sendHttpRequestDefault,
+) => {
+  const httpRequest = {
+    url: `${baseUrl}/odata/v1/ODataServlet/Sessions('${biometricSessionId}')`,
+    method: "GET" as const,
+    headers: {
+      "X-Innovalor-Authorization": viewerKey,
+      Accept: "application/json;odata.metadata=minimal",
+      "Content-Type": "application/json;odata.metadata=minimal",
+      "OData-MaxVersion": "4.0",
+      "OData-Version": "4.0",
+    },
+  };
+
+  const retryConfig: RetryConfig = {
+    retryableStatusCodes: [
+      429, 500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511,
+    ],
+    maxAttempts: 3,
+    delayInMillis: 50,
+  };
+
+  logger.debug(LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_ATTEMPT, {
+    data: {
+      biometricSessionId,
+    },
+  });
+
+  const getBiometricSessionResult: Result<SuccessfulHttpResponse, HttpError> =
+    await sendHttpRequest(httpRequest, retryConfig);
+
+  if (getBiometricSessionResult.isError) {
+    const error = getBiometricSessionResult.value;
+    const statusCode = error.statusCode ? Number(error.statusCode) : undefined;
+
+    const isRetryable =
+      !statusCode ||
+      statusCode === 429 ||
+      (statusCode >= 500 && statusCode < 600);
+
+    logger.error(
+      LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE,
+      {
+        data: {
+          error,
+          isRetryable,
+          biometricSessionId,
+        },
+      },
+    );
+
+    return errorResult({
+      statusCode,
+      message: "Failed to retrieve biometric session",
+      isRetryable,
+    });
+  }
+
+  const getBiometricSessionResponse = getBiometricSessionResult.value;
+
+  if (getBiometricSessionResponse?.body == null) {
+    logger.error(
+      LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE,
+      {
+        data: {
+          getBiometricSessionResponse,
+          biometricSessionId,
+        },
+      },
+    );
+
+    return errorResult({
+      message: "Empty response body from ReadID",
+      isRetryable: true,
+    });
+  }
+
+  let parsedBody;
+  try {
+    parsedBody = JSON.parse(getBiometricSessionResponse.body);
+  } catch (error) {
+    logger.error(
+      LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE,
+      {
+        data: {
+          error,
+          biometricSessionId,
+        },
+      },
+    );
+
+    return errorResult({
+      message: "Failed to parse response JSON",
+      isRetryable: true,
+    });
+  }
+
+  // Check if the response has the expected structure
+  if (!parsedBody || typeof parsedBody.finish !== "string") {
+    logger.error(
+      LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE,
+      {
+        data: {
+          parsedBody,
path: backend-api/src/functions/asyncIssueBiometricCredential/getBiometricSession/getBiometricSession.ts
user: login: tmulberry-gds
body: Yeah, removed the parsedbody log
created_at: 2025-04-17T09:38:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,157 @@
+import { logger } from "../../common/logging/logger";
+import { LogMessage } from "../../common/logging/LogMessage";
+import {
+  HttpError,
+  ISendHttpRequest,
+  RetryConfig,
+  sendHttpRequest as sendHttpRequestDefault,
+  SuccessfulHttpResponse,
+} from "../../adapters/http/sendHttpRequest";
+import { Result, successResult, errorResult } from "../../utils/result";
+
+export interface BiometricSession {
+  id: string;
+  finish: string;
+}
+
+export interface BiometricSessionError {
+  statusCode?: number;
+  message: string;
+  isRetryable: boolean;
+}
+
+export type GetBiometricSession = (
+  baseUrl: string,
+  biometricSessionId: string,
+  viewerKey: string,
+  sendHttpRequest?: ISendHttpRequest,
+) => Promise<Result<BiometricSession, BiometricSessionError>>;
+
+export const getBiometricSession: GetBiometricSession = async (
+  baseUrl: string,
+  biometricSessionId: string,
+  viewerKey: string,
+  sendHttpRequest: ISendHttpRequest = sendHttpRequestDefault,
+) => {
+  const httpRequest = {
+    url: `${baseUrl}/odata/v1/ODataServlet/Sessions('${biometricSessionId}')`,
+    method: "GET" as const,
+    headers: {
+      "X-Innovalor-Authorization": viewerKey,
+      Accept: "application/json;odata.metadata=minimal",
+      "Content-Type": "application/json;odata.metadata=minimal",
+      "OData-MaxVersion": "4.0",
+      "OData-Version": "4.0",
+    },
+  };
+
+  const retryConfig: RetryConfig = {
+    retryableStatusCodes: [
+      429, 500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511,
+    ],
+    maxAttempts: 3,
+    delayInMillis: 50,
+  };
+
+  logger.debug(LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_ATTEMPT, {
+    data: {
+      biometricSessionId,
+    },
+  });
+
+  const getBiometricSessionResult: Result<SuccessfulHttpResponse, HttpError> =
+    await sendHttpRequest(httpRequest, retryConfig);
+
+  if (getBiometricSessionResult.isError) {
+    const error = getBiometricSessionResult.value;
+    const statusCode = error.statusCode ? Number(error.statusCode) : undefined;
+
+    const isRetryable =
+      !statusCode ||
+      statusCode === 429 ||
+      (statusCode >= 500 && statusCode < 600);
+
+    logger.error(
+      LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE,
+      {
+        data: {
+          error,
+          isRetryable,
+          biometricSessionId,
+        },
+      },
+    );
+
+    return errorResult({
+      statusCode,
+      message: "Failed to retrieve biometric session",
+      isRetryable,
+    });
+  }
+
+  const getBiometricSessionResponse = getBiometricSessionResult.value;
+
+  if (getBiometricSessionResponse?.body == null) {
+    logger.error(
+      LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE,
+      {
+        data: {
+          getBiometricSessionResponse,
+          biometricSessionId,
+        },
+      },
+    );
+
+    return errorResult({
+      message: "Empty response body from ReadID",
+      isRetryable: true,
+    });
+  }
+
+  let parsedBody;
+  try {
+    parsedBody = JSON.parse(getBiometricSessionResponse.body);
+  } catch (error) {
+    logger.error(
+      LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE,
+      {
+        data: {
+          error,
+          biometricSessionId,
+        },
+      },
+    );
+
+    return errorResult({
+      message: "Failed to parse response JSON",
+      isRetryable: true,
+    });
+  }
+
+  // Check if the response has the expected structure
+  if (!parsedBody || typeof parsedBody.finish !== "string") {
+    logger.error(
+      LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE,
+      {
+        data: {
+          parsedBody,
path: backend-api/src/functions/asyncIssueBiometricCredential/getBiometricSession/getBiometricSession.ts
user: login: tmulberry-gds
body: And also added a different field
created_at: 2025-04-17T09:38:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -74,7 +79,91 @@ export async function lambdaHandlerConstructor(
   if (viewerKeyResult.isError) {
     throw new RetainMessageOnQueue("Failed to retrieve biometric viewer key");
   }
+  const submitterKey = viewerKeyResult.value;
+
+  const biometricSessionResult = await dependencies.getBiometricSession(
+    config.READID_BASE_URL,
+    sessionId,
+    submitterKey,
+  );
+
+  if (biometricSessionResult.isError) {
+    const eventService = dependencies.getEventService(config.TXMA_SQS);
+    const error: BiometricSessionError = biometricSessionResult.value;
+
+    // Check if the error was retryable based on error info
+    if (error.isRetryable) {
+      logger.error(LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_RETRYABLE_ERROR);
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.ts
user: login: tmulberry-gds
body: Pushed
created_at: 2025-04-17T09:40:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -74,7 +79,91 @@ export async function lambdaHandlerConstructor(
   if (viewerKeyResult.isError) {
     throw new RetainMessageOnQueue("Failed to retrieve biometric viewer key");
   }
+  const submitterKey = viewerKeyResult.value;
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.ts
user: login: tmulberry-gds
body: Pushed
created_at: 2025-04-17T09:40:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -74,7 +79,91 @@ export async function lambdaHandlerConstructor(
   if (viewerKeyResult.isError) {
     throw new RetainMessageOnQueue("Failed to retrieve biometric viewer key");
   }
+  const submitterKey = viewerKeyResult.value;
+
+  const biometricSessionResult = await dependencies.getBiometricSession(
+    config.READID_BASE_URL,
+    sessionId,
+    submitterKey,
+  );
+
+  if (biometricSessionResult.isError) {
+    const eventService = dependencies.getEventService(config.TXMA_SQS);
+    const error: BiometricSessionError = biometricSessionResult.value;
+
+    // Check if the error was retryable based on error info
+    if (error.isRetryable) {
+      logger.error(LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_RETRYABLE_ERROR);
+      throw new RetainMessageOnQueue(
+        `Retryable error (${error.statusCode || "unknown"}) retrieving biometric session`,
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.ts
user: login: tmulberry-gds
body: Pushed
created_at: 2025-04-17T09:40:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -74,7 +79,91 @@ export async function lambdaHandlerConstructor(
   if (viewerKeyResult.isError) {
     throw new RetainMessageOnQueue("Failed to retrieve biometric viewer key");
   }
+  const submitterKey = viewerKeyResult.value;
+
+  const biometricSessionResult = await dependencies.getBiometricSession(
+    config.READID_BASE_URL,
+    sessionId,
+    submitterKey,
+  );
+
+  if (biometricSessionResult.isError) {
+    const eventService = dependencies.getEventService(config.TXMA_SQS);
+    const error: BiometricSessionError = biometricSessionResult.value;
+
+    // Check if the error was retryable based on error info
+    if (error.isRetryable) {
+      logger.error(LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_RETRYABLE_ERROR);
+      throw new RetainMessageOnQueue(
+        `Retryable error (${error.statusCode || "unknown"}) retrieving biometric session`,
+      );
+    }
+
+    // Non-retryable error - send error to IPV Core
+    logger.error(LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_NON_RETRYABLE_ERROR, {
+      data: {
+        sessionId,
+        error,
+      },
+    });
+
+    if (
+      sessionAttributes?.subjectIdentifier &&
+      sessionAttributes?.clientState
+    ) {
+      const ipvCoreOutboundMessage: IssueBiometricCredentialMessage = {
+        sub: sessionAttributes.subjectIdentifier,
+        state: sessionAttributes.clientState,
+        error: "server_error",
+        error_description: "Failed to retrieve biometric session from ReadID",
+      };
+
+      const sendMessageToIPVCoreOutboundQueueResult =
+        await dependencies.sendMessageToSqs(
+          config.IPVCORE_OUTBOUND_SQS,
+          ipvCoreOutboundMessage,
+        );
+
+      if (sendMessageToIPVCoreOutboundQueueResult.isError) {
+        logger.error(
+          LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_IPV_CORE_MESSAGE_ERROR,
+        );
+      }
+    }
+
+    const writeEventResult = await eventService.writeGenericEvent({
+      eventName: "DCMAW_ASYNC_CRI_5XXERROR",
+      sub: sessionAttributes?.subjectIdentifier,
+      sessionId,
+      govukSigninJourneyId: sessionAttributes?.govukSigninJourneyId,
+      getNowInMilliseconds: Date.now,
+      componentId: config.ISSUER,
+      ipAddress: undefined,
+      txmaAuditEncoded: undefined,
+      redirect_uri: sessionAttributes?.redirectUri,
+      suspected_fraud_signal: undefined,
+    });
+
+    if (writeEventResult.isError) {
+      logger.error(LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_TXMA_EVENT_ERROR);
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.ts
user: login: tmulberry-gds
body: Changed to 'ERROR_WRITING_AUDIT_EVENT'
created_at: 2025-04-17T09:41:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -311,6 +311,50 @@ export class LogMessage implements LogAttributes {
     "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_INVALID_CONFIG",
     "One or more required environment variables were missing or invalid.",
   );
+
+  static readonly ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE =
+    new LogMessage(
+      "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE",
+      "Failed to retrieve biometric session from ReadID",
+    );
+  static readonly ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_SUCCESS =
+    new LogMessage(
+      "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_SUCCESS",
+      "Successfully retrieved biometric session from ReadID",
+    );
+  static readonly ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_ATTEMPT =
+    new LogMessage(
+      "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_ATTEMPT",
+      "Retrieving biometric session from ReadID",
+    );
+  static readonly ISSUE_BIOMETRIC_CREDENTIAL_NOT_READY = new LogMessage(
+    "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_NOT_READY",
+    "Biometric session not ready (finish status not DONE)",
+  );
+
+  static readonly ISSUE_BIOMETRIC_CREDENTIAL_IPV_CORE_MESSAGE_ERROR =
+    new LogMessage(
+      "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_IPV_CORE_MESSAGE_ERROR",
+      "Error sending message to IPV Core",
+    );
+  static readonly ISSUE_BIOMETRIC_CREDENTIAL_TXMA_EVENT_ERROR = new LogMessage(
+    "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_TXMA_EVENT_ERROR",
+    "Error sending event to TxMA",
+  );
path: backend-api/src/functions/common/logging/LogMessage.ts
user: login: tmulberry-gds
body: Removed
created_at: 2025-04-17T09:41:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -311,6 +311,50 @@ export class LogMessage implements LogAttributes {
     "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_INVALID_CONFIG",
     "One or more required environment variables were missing or invalid.",
   );
+
+  static readonly ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE =
+    new LogMessage(
+      "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE",
+      "Failed to retrieve biometric session from ReadID",
+    );
+  static readonly ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_SUCCESS =
+    new LogMessage(
+      "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_SUCCESS",
+      "Successfully retrieved biometric session from ReadID",
+    );
+  static readonly ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_ATTEMPT =
+    new LogMessage(
+      "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_ATTEMPT",
+      "Retrieving biometric session from ReadID",
+    );
+  static readonly ISSUE_BIOMETRIC_CREDENTIAL_NOT_READY = new LogMessage(
+    "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_NOT_READY",
+    "Biometric session not ready (finish status not DONE)",
+  );
+
+  static readonly ISSUE_BIOMETRIC_CREDENTIAL_IPV_CORE_MESSAGE_ERROR =
+    new LogMessage(
+      "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_IPV_CORE_MESSAGE_ERROR",
+      "Error sending message to IPV Core",
+    );
+  static readonly ISSUE_BIOMETRIC_CREDENTIAL_TXMA_EVENT_ERROR = new LogMessage(
+    "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_TXMA_EVENT_ERROR",
+    "Error sending event to TxMA",
+  );
+  static readonly SESSION_NOT_FOUND = new LogMessage(
+    "MOBILE_ASYNC_SESSION_NOT_FOUND",
+    "Session not found in session registry",
+  );
path: backend-api/src/functions/common/logging/LogMessage.ts
user: login: tmulberry-gds
body: Removed it as there is actually one already there
created_at: 2025-04-17T09:45:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -74,7 +79,90 @@ export async function lambdaHandlerConstructor(
   if (viewerKeyResult.isError) {
     throw new RetainMessageOnQueue("Failed to retrieve biometric viewer key");
   }
+  const viewerKey = viewerKeyResult.value;
+
+  const biometricSessionResult = await dependencies.getBiometricSession(
+    config.READID_BASE_URL,
+    sessionId,
+    viewerKey,
+  );
+
+  if (biometricSessionResult.isError) {
+    const eventService = dependencies.getEventService(config.TXMA_SQS);
+    const error: BiometricSessionError = biometricSessionResult.value;
+
+    // Check if the error was retryable based on error info
+    if (error.isRetryable) {
+      throw new RetainMessageOnQueue(
+        `Retryable error (status code: ${error.statusCode || "N/A"}) retrieving biometric session`,
+      );
+    }
+
+    // Non-retryable error - send error to IPV Core
+    logger.error(LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_NON_RETRYABLE_ERROR, {
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.ts
user: login: tmulberry-gds
body: Removed it
created_at: 2025-04-17T09:49:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -74,7 +79,90 @@ export async function lambdaHandlerConstructor(
   if (viewerKeyResult.isError) {
     throw new RetainMessageOnQueue("Failed to retrieve biometric viewer key");
   }
+  const viewerKey = viewerKeyResult.value;
+
+  const biometricSessionResult = await dependencies.getBiometricSession(
+    config.READID_BASE_URL,
+    sessionId,
+    viewerKey,
+  );
+
+  if (biometricSessionResult.isError) {
+    const eventService = dependencies.getEventService(config.TXMA_SQS);
+    const error: BiometricSessionError = biometricSessionResult.value;
+
+    // Check if the error was retryable based on error info
+    if (error.isRetryable) {
+      throw new RetainMessageOnQueue(
+        `Retryable error (status code: ${error.statusCode || "N/A"}) retrieving biometric session`,
+      );
+    }
+
+    // Non-retryable error - send error to IPV Core
+    logger.error(LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_NON_RETRYABLE_ERROR, {
+      data: {
+        sessionId,
+        error,
+      },
+    });
+
+    if (
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.ts
user: login: tmulberry-gds
body: Removed the conditional and left the inside
created_at: 2025-04-17T09:50:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -74,7 +79,90 @@ export async function lambdaHandlerConstructor(
   if (viewerKeyResult.isError) {
     throw new RetainMessageOnQueue("Failed to retrieve biometric viewer key");
   }
+  const viewerKey = viewerKeyResult.value;
+
+  const biometricSessionResult = await dependencies.getBiometricSession(
+    config.READID_BASE_URL,
+    sessionId,
+    viewerKey,
+  );
+
+  if (biometricSessionResult.isError) {
+    const eventService = dependencies.getEventService(config.TXMA_SQS);
+    const error: BiometricSessionError = biometricSessionResult.value;
+
+    // Check if the error was retryable based on error info
+    if (error.isRetryable) {
+      throw new RetainMessageOnQueue(
+        `Retryable error (status code: ${error.statusCode || "N/A"}) retrieving biometric session`,
+      );
+    }
+
+    // Non-retryable error - send error to IPV Core
+    logger.error(LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_NON_RETRYABLE_ERROR, {
+      data: {
+        sessionId,
+        error,
+      },
+    });
+
+    if (
+      sessionAttributes?.subjectIdentifier &&
+      sessionAttributes?.clientState
+    ) {
+      const ipvCoreOutboundMessage: IssueBiometricCredentialMessage = {
+        sub: sessionAttributes.subjectIdentifier,
+        state: sessionAttributes.clientState,
+        error: "server_error",
+        error_description: "Failed to retrieve biometric session from ReadID",
+      };
+
+      const sendMessageToIPVCoreOutboundQueueResult =
+        await dependencies.sendMessageToSqs(
+          config.IPVCORE_OUTBOUND_SQS,
+          ipvCoreOutboundMessage,
+        );
+
+      if (sendMessageToIPVCoreOutboundQueueResult.isError) {
+        logger.error(
+          LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_IPV_CORE_MESSAGE_ERROR,
+        );
+      }
+    }
+
+    const writeEventResult = await eventService.writeGenericEvent({
+      eventName: "DCMAW_ASYNC_CRI_5XXERROR",
+      sub: sessionAttributes?.subjectIdentifier,
+      sessionId,
+      govukSigninJourneyId: sessionAttributes?.govukSigninJourneyId,
+      getNowInMilliseconds: Date.now,
+      componentId: config.ISSUER,
+      ipAddress: undefined,
+      txmaAuditEncoded: undefined,
+      redirect_uri: sessionAttributes?.redirectUri,
+      suspected_fraud_signal: undefined,
+    });
+
+    if (writeEventResult.isError) {
+      logger.error(LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_TXMA_EVENT_ERROR);
+    }
+
+    return;
+  }
+
+  const session = biometricSessionResult.value;
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.ts
user: login: tmulberry-gds
body: Renamed to biometricSession
created_at: 2025-04-17T09:50:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -74,7 +79,90 @@ export async function lambdaHandlerConstructor(
   if (viewerKeyResult.isError) {
     throw new RetainMessageOnQueue("Failed to retrieve biometric viewer key");
   }
+  const viewerKey = viewerKeyResult.value;
+
+  const biometricSessionResult = await dependencies.getBiometricSession(
+    config.READID_BASE_URL,
+    sessionId,
+    viewerKey,
+  );
+
+  if (biometricSessionResult.isError) {
+    const eventService = dependencies.getEventService(config.TXMA_SQS);
+    const error: BiometricSessionError = biometricSessionResult.value;
+
+    // Check if the error was retryable based on error info
+    if (error.isRetryable) {
+      throw new RetainMessageOnQueue(
+        `Retryable error (status code: ${error.statusCode || "N/A"}) retrieving biometric session`,
+      );
+    }
+
+    // Non-retryable error - send error to IPV Core
+    logger.error(LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_NON_RETRYABLE_ERROR, {
+      data: {
+        sessionId,
+        error,
+      },
+    });
+
+    if (
+      sessionAttributes?.subjectIdentifier &&
+      sessionAttributes?.clientState
+    ) {
+      const ipvCoreOutboundMessage: IssueBiometricCredentialMessage = {
+        sub: sessionAttributes.subjectIdentifier,
+        state: sessionAttributes.clientState,
+        error: "server_error",
+        error_description: "Failed to retrieve biometric session from ReadID",
+      };
+
+      const sendMessageToIPVCoreOutboundQueueResult =
+        await dependencies.sendMessageToSqs(
+          config.IPVCORE_OUTBOUND_SQS,
+          ipvCoreOutboundMessage,
+        );
+
+      if (sendMessageToIPVCoreOutboundQueueResult.isError) {
+        logger.error(
+          LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_IPV_CORE_MESSAGE_ERROR,
+        );
+      }
+    }
+
+    const writeEventResult = await eventService.writeGenericEvent({
+      eventName: "DCMAW_ASYNC_CRI_5XXERROR",
+      sub: sessionAttributes?.subjectIdentifier,
+      sessionId,
+      govukSigninJourneyId: sessionAttributes?.govukSigninJourneyId,
+      getNowInMilliseconds: Date.now,
+      componentId: config.ISSUER,
+      ipAddress: undefined,
+      txmaAuditEncoded: undefined,
+      redirect_uri: sessionAttributes?.redirectUri,
+      suspected_fraud_signal: undefined,
+    });
+
+    if (writeEventResult.isError) {
+      logger.error(LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_TXMA_EVENT_ERROR);
+    }
+
+    return;
+  }
+
+  const session = biometricSessionResult.value;
+
+  if (session.finish !== "DONE") {
+    logger.info(LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_NOT_READY, {
+      data: { finish: session.finish },
+    });
+    logger.info(LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_COMPLETED);
+    throw new RetainMessageOnQueue(
+      `Biometric session not ready: ${session.finish}`,
+    );
+  }
 
+  // Session is ready, continue processing
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.ts
user: login: tmulberry-gds
body: Updated to biometric session
created_at: 2025-04-17T09:50:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -74,7 +79,90 @@ export async function lambdaHandlerConstructor(
   if (viewerKeyResult.isError) {
     throw new RetainMessageOnQueue("Failed to retrieve biometric viewer key");
   }
+  const viewerKey = viewerKeyResult.value;
+
+  const biometricSessionResult = await dependencies.getBiometricSession(
+    config.READID_BASE_URL,
+    sessionId,
+    viewerKey,
+  );
+
+  if (biometricSessionResult.isError) {
+    const eventService = dependencies.getEventService(config.TXMA_SQS);
+    const error: BiometricSessionError = biometricSessionResult.value;
+
+    // Check if the error was retryable based on error info
+    if (error.isRetryable) {
+      throw new RetainMessageOnQueue(
+        `Retryable error (status code: ${error.statusCode || "N/A"}) retrieving biometric session`,
+      );
+    }
+
+    // Non-retryable error - send error to IPV Core
+    logger.error(LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_NON_RETRYABLE_ERROR, {
+      data: {
+        sessionId,
+        error,
+      },
+    });
+
+    if (
+      sessionAttributes?.subjectIdentifier &&
+      sessionAttributes?.clientState
+    ) {
+      const ipvCoreOutboundMessage: IssueBiometricCredentialMessage = {
+        sub: sessionAttributes.subjectIdentifier,
+        state: sessionAttributes.clientState,
+        error: "server_error",
+        error_description: "Failed to retrieve biometric session from ReadID",
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.ts
user: login: tmulberry-gds
body: Updates this. 

For future implementations, not this ticket, wondering if its a good idea to have a file with consts for error responses i.e.:

export const serverError = {
      error: "server_error",
      error_description: "Internal server error",
}

and call it like:

const ipvCoreOutboundMessage: IssueBiometricCredentialMessage = {
      sub: sessionAttributes.subjectIdentifier,
      state: sessionAttributes.clientState,
      ...serverError,
};
created_at: 2025-04-17T09:55:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -10,4 +10,14 @@ export interface AbortSessionMessage {
   error_description: string;
 }
 
-export type SQSMessageBody = VendorProcessingMessage | AbortSessionMessage;
+export interface IssueBiometricCredentialMessage {
path: backend-api/src/functions/adapters/aws/sqs/types.ts
user: login: tmulberry-gds
body: Yeah good point. I merged them both into OutboundQueueErrorMessage, and updated the relevant lambdas and their dependencies
created_at: 2025-04-17T10:16:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,155 @@
+import { logger } from "../../common/logging/logger";
+import { LogMessage } from "../../common/logging/LogMessage";
+import {
+  HttpError,
+  ISendHttpRequest,
+  RetryConfig,
+  sendHttpRequest as sendHttpRequestDefault,
+  SuccessfulHttpResponse,
+} from "../../adapters/http/sendHttpRequest";
+import { Result, successResult, errorResult } from "../../utils/result";
+
+export interface BiometricSession {
+  id: string;
+  finish: string;
+}
+
+export interface GetBiometricSessionError {
+  statusCode?: number;
+  message: string;
+  isRetryable: boolean;
+}
+
+export type GetBiometricSession = (
+  baseUrl: string,
+  biometricSessionId: string,
+  viewerKey: string,
+  sendHttpRequest?: ISendHttpRequest,
+) => Promise<Result<BiometricSession, GetBiometricSessionError>>;
+
+export const getBiometricSession: GetBiometricSession = async (
+  baseUrl: string,
+  biometricSessionId: string,
+  viewerKey: string,
+  sendHttpRequest: ISendHttpRequest = sendHttpRequestDefault,
+) => {
+  const httpRequest = {
+    url: `${baseUrl}/odata/v1/ODataServlet/Sessions('${biometricSessionId}')`,
+    method: "GET" as const,
+    headers: {
+      "X-Innovalor-Authorization": viewerKey,
+      Accept: "application/json;odata.metadata=minimal",
+      "Content-Type": "application/json;odata.metadata=minimal",
+      "OData-MaxVersion": "4.0",
+      "OData-Version": "4.0",
+    },
+  };
+
+  const retryConfig: RetryConfig = {
+    retryableStatusCodes: [
+      429, 500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511,
+    ],
+    delayInMillis: 50,
+  };
+
+  logger.debug(LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_ATTEMPT, {
+    data: {
+      biometricSessionId,
+    },
+  });
+
+  const getBiometricSessionResult: Result<SuccessfulHttpResponse, HttpError> =
+    await sendHttpRequest(httpRequest, retryConfig);
+
+  if (getBiometricSessionResult.isError) {
+    const error = getBiometricSessionResult.value;
+    const statusCode = error.statusCode ? Number(error.statusCode) : undefined;
+    const retryableStatusCodes = retryConfig.retryableStatusCodes;
+
+    const isRetryable =
+      !statusCode || (retryableStatusCodes?.includes(statusCode) ?? false);
+
+    logger.error(
+      LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE,
+      {
+        data: {
+          error,
+          isRetryable,
+          biometricSessionId,
+        },
+      },
+    );
+
+    return errorResult({
+      statusCode,
+      message: "Failed to retrieve biometric session",
+      isRetryable,
+    });
+  }
+
+  const getBiometricSessionResponse = getBiometricSessionResult.value;
+
+  if (getBiometricSessionResponse?.body == null) {
+    logger.error(
+      LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE,
+      {
+        data: {
+          getBiometricSessionResponse,
+          biometricSessionId,
+        },
+      },
+    );
+
+    return errorResult({
+      message: "Empty response body from ReadID",
+      isRetryable: true,
path: backend-api/src/functions/asyncIssueBiometricCredential/getBiometricSession/getBiometricSession.ts
user: login: tmulberry-gds
body: Sorry, missed it. Updated it now
created_at: 2025-04-17T10:17:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -8,8 +8,10 @@ import { logger } from "../common/logging/logger";
 import { LogMessage } from "../common/logging/LogMessage";
 
 const REQUIRED_ENVIRONMENT_VARIABLES = [
+  "READID_BASE_URL",
path: backend-api/src/functions/asyncIssueBiometricCredential/issueBiometricCredentialConfig.ts
user: login: tmulberry-gds
body: Thats true, I updated the lambda infra file to include it
created_at: 2025-04-17T10:19:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,155 @@
+import { logger } from "../../common/logging/logger";
+import { LogMessage } from "../../common/logging/LogMessage";
+import {
+  HttpError,
+  ISendHttpRequest,
+  RetryConfig,
+  sendHttpRequest as sendHttpRequestDefault,
+  SuccessfulHttpResponse,
+} from "../../adapters/http/sendHttpRequest";
+import { Result, successResult, errorResult } from "../../utils/result";
+
+export interface BiometricSession {
+  id: string;
+  finish: string;
+}
+
+export interface GetBiometricSessionError {
+  statusCode?: number;
+  message: string;
+  isRetryable: boolean;
+}
+
+export type GetBiometricSession = (
+  baseUrl: string,
+  biometricSessionId: string,
+  viewerKey: string,
+  sendHttpRequest?: ISendHttpRequest,
+) => Promise<Result<BiometricSession, GetBiometricSessionError>>;
+
+export const getBiometricSession: GetBiometricSession = async (
+  baseUrl: string,
+  biometricSessionId: string,
+  viewerKey: string,
+  sendHttpRequest: ISendHttpRequest = sendHttpRequestDefault,
+) => {
+  const httpRequest = {
+    url: `${baseUrl}/odata/v1/ODataServlet/Sessions('${biometricSessionId}')`,
+    method: "GET" as const,
+    headers: {
+      "X-Innovalor-Authorization": viewerKey,
+      Accept: "application/json;odata.metadata=minimal",
+      "Content-Type": "application/json;odata.metadata=minimal",
+      "OData-MaxVersion": "4.0",
+      "OData-Version": "4.0",
+    },
+  };
+
+  const retryConfig: RetryConfig = {
+    retryableStatusCodes: [
+      429, 500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511,
+    ],
+    delayInMillis: 50,
+  };
+
+  logger.debug(LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_ATTEMPT, {
+    data: {
+      biometricSessionId,
+    },
+  });
+
+  const getBiometricSessionResult: Result<SuccessfulHttpResponse, HttpError> =
+    await sendHttpRequest(httpRequest, retryConfig);
+
+  if (getBiometricSessionResult.isError) {
+    const error = getBiometricSessionResult.value;
+    const statusCode = error.statusCode ? Number(error.statusCode) : undefined;
+    const retryableStatusCodes = retryConfig.retryableStatusCodes;
+
+    const isRetryable =
+      !statusCode || (retryableStatusCodes?.includes(statusCode) ?? false);
path: backend-api/src/functions/asyncIssueBiometricCredential/getBiometricSession/getBiometricSession.ts
user: login: tmulberry-gds
body: I had it as ?? false as retryableStatusCodes are technically optional field so can be undefined. I just removed the :RetryConfig from `const retryConfig` and updated the line to remove ??false
created_at: 2025-04-17T10:21:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,152 @@
+import { logger } from "../../common/logging/logger";
+import { LogMessage } from "../../common/logging/LogMessage";
+import {
+  HttpError,
+  ISendHttpRequest,
+  RetryConfig,
+  sendHttpRequest as sendHttpRequestDefault,
+  SuccessfulHttpResponse,
+} from "../../adapters/http/sendHttpRequest";
+import { Result, successResult, errorResult } from "../../utils/result";
+
+export interface BiometricSession {
+  finish: string;
+}
+
+export interface GetBiometricSessionError {
+  isRetryable: boolean;
+}
+
+export type GetBiometricSession = (
+  baseUrl: string,
+  biometricSessionId: string,
+  viewerKey: string,
+  sendHttpRequest?: ISendHttpRequest,
+) => Promise<Result<BiometricSession, GetBiometricSessionError>>;
+
+export const getBiometricSession: GetBiometricSession = async (
+  baseUrl: string,
+  biometricSessionId: string,
+  viewerKey: string,
+  sendHttpRequest: ISendHttpRequest = sendHttpRequestDefault,
+) => {
+  const httpRequest = {
+    url: `${baseUrl}/odata/v1/ODataServlet/Sessions('${biometricSessionId}')`,
+    method: "GET" as const,
+    headers: {
+      "X-Innovalor-Authorization": viewerKey,
+      Accept: "application/json;odata.metadata=minimal",
+      "Content-Type": "application/json;odata.metadata=minimal",
+      "OData-MaxVersion": "4.0",
+      "OData-Version": "4.0",
+    },
+  };
+
+  const retryConfig: RetryConfig = {
+    retryableStatusCodes: [
+      429, 500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511,
+    ],
+    delayInMillis: 50,
+  };
+
+  logger.debug(LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_ATTEMPT, {
+    data: {
+      biometricSessionId,
+    },
+  });
+
+  const getBiometricSessionResult: Result<SuccessfulHttpResponse, HttpError> =
+    await sendHttpRequest(httpRequest, retryConfig);
+
+  if (getBiometricSessionResult.isError) {
+    const error = getBiometricSessionResult.value;
+    const statusCode = error.statusCode ? Number(error.statusCode) : undefined;
+    const retryableStatusCodes = retryConfig.retryableStatusCodes;
+
+    const isRetryable =
+      !statusCode || (retryableStatusCodes?.includes(statusCode) ?? false);
+
+    logger.error(
+      LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE,
+      {
+        data: {
+          error,
+          isRetryable,
+          biometricSessionId,
+        },
+      },
+    );
+
+    return errorResult({
+      statusCode,
+      message: "Failed to retrieve biometric session",
+      isRetryable,
+    });
+  }
+
+  const getBiometricSessionResponse = getBiometricSessionResult.value;
+
+  if (getBiometricSessionResponse?.body == null) {
+    logger.error(
+      LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE,
+      {
+        data: {
+          getBiometricSessionResponse,
+          biometricSessionId,
+        },
+      },
+    );
+
+    return errorResult({
+      message: "Empty response body from ReadID",
+      isRetryable: false,
+    });
+  }
+
+  let parsedBody;
+  try {
+    parsedBody = JSON.parse(getBiometricSessionResponse.body);
+  } catch (error) {
+    logger.error(
+      LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE,
+      {
+        data: {
+          error,
+          biometricSessionId,
+        },
+      },
+    );
+
+    return errorResult({
+      message: "Failed to parse response JSON",
+      isRetryable: false,
+    });
+  }
+
+  // Check if the response has the expected structure
+  if (!parsedBody || typeof parsedBody.finish !== "string") {
+    logger.error(
+      LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE,
+      {
+        data: {
+          validFinishProperty: parsedBody?.finish,
path: backend-api/src/functions/asyncIssueBiometricCredential/getBiometricSession/getBiometricSession.ts
user: login: chris-cooksley-gds
body: ```suggestion
          invalidFinishProperty: parsedBody?.finish,
```
created_at: 2025-04-17T10:25:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -74,7 +79,90 @@ export async function lambdaHandlerConstructor(
   if (viewerKeyResult.isError) {
     throw new RetainMessageOnQueue("Failed to retrieve biometric viewer key");
   }
+  const viewerKey = viewerKeyResult.value;
+
+  const biometricSessionResult = await dependencies.getBiometricSession(
+    config.READID_BASE_URL,
+    sessionId,
+    viewerKey,
+  );
+
+  if (biometricSessionResult.isError) {
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.ts
user: login: tmulberry-gds
body: Extracted ipvCoreOutboundMessage into a function at the bottom of the file, let me know your thoughts. Would like this to be (in future) in its own file/class
created_at: 2025-04-17T10:44:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -74,7 +79,90 @@ export async function lambdaHandlerConstructor(
   if (viewerKeyResult.isError) {
     throw new RetainMessageOnQueue("Failed to retrieve biometric viewer key");
   }
+  const viewerKey = viewerKeyResult.value;
+
+  const biometricSessionResult = await dependencies.getBiometricSession(
+    config.READID_BASE_URL,
+    sessionId,
+    viewerKey,
+  );
+
+  if (biometricSessionResult.isError) {
+    const eventService = dependencies.getEventService(config.TXMA_SQS);
+    const error: BiometricSessionError = biometricSessionResult.value;
+
+    // Check if the error was retryable based on error info
+    if (error.isRetryable) {
+      throw new RetainMessageOnQueue(
+        `Retryable error (status code: ${error.statusCode || "N/A"}) retrieving biometric session`,
+      );
+    }
+
+    // Non-retryable error - send error to IPV Core
+    logger.error(LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_NON_RETRYABLE_ERROR, {
+      data: {
+        sessionId,
+        error,
+      },
+    });
+
+    if (
+      sessionAttributes?.subjectIdentifier &&
+      sessionAttributes?.clientState
+    ) {
+      const ipvCoreOutboundMessage: IssueBiometricCredentialMessage = {
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.ts
user: login: tmulberry-gds
body: See comment on other
created_at: 2025-04-17T10:44:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -38,19 +45,28 @@ export async function lambdaHandlerConstructor(
   if (validateSqsEventResult.isError) {
     return;
   }
+
   const sessionId = validateSqsEventResult.value;
 
   logger.appendKeys({ sessionId });
 
   const sessionRegistry = dependencies.getSessionRegistry(
     config.SESSION_TABLE_NAME,
   );
+
   const eventService = dependencies.getEventService(config.TXMA_SQS);
 
   const getSessionResult = await sessionRegistry.getSession(
     sessionId,
-    new GetSessionIssueBiometricCredential(),
+    new GetSessionBiometricTokenIssued(),
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.ts
user: login: tmulberry-gds
body: Oh, this was my mistake. I read comment as a commit not a diff, which is why it was changed
created_at: 2025-04-17T10:58:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -133,6 +195,30 @@ const handleGetSessionError = async (
   }
 };
 
+const handleSendMessageToOutboundQueue = async (
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.ts
user: login: chris-cooksley-gds
body: This function only sends a server error message to the outbound queue (as opposed to a success message or some other kind of error). Could we either (a) make it suitably generic so that it can be re-used for other errors/non-errors, or (b) rename it to indicate that it only sends server errors?
created_at: 2025-04-17T11:10:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -74,7 +83,60 @@ export async function lambdaHandlerConstructor(
   if (viewerKeyResult.isError) {
     throw new RetainMessageOnQueue("Failed to retrieve biometric viewer key");
   }
+  const viewerKey = viewerKeyResult.value;
+
+  const biometricSessionResult = await dependencies.getBiometricSession(
+    config.READID_BASE_URL,
+    sessionAttributes.sessionId,
+    viewerKey,
+  );
+
+  if (biometricSessionResult.isError) {
+    const eventService = dependencies.getEventService(config.TXMA_SQS);
+    const error: GetBiometricSessionError = biometricSessionResult.value;
+
+    // Check if the error was retryable based on error info
+    if (error.isRetryable) {
+      throw new RetainMessageOnQueue(
+        `Retryable error retrieving biometric session`,
+      );
+    }
+
+    handleSendMessageToOutboundQueue(dependencies, sessionAttributes, config);
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.ts
user: login: chris-cooksley-gds
body: I think we need to `await` this since it's asynchronous.
created_at: 2025-04-17T11:13:47Z
author_association: CONTRIBUTOR
path: backend-api/infra/functions/issueBiometricCredential.yaml
user: login: chris-cooksley-gds
body: Reminder (commenting here just to have somewhere to put it) - once the PR is ready for review, can we please test this in dev (against the readid mock) and attach evidence to the ticket to confirm that it works as expected? Will have to be a manual test for now - i.e. put a message in the vendor processing queue and show in the logs that we process it successfully
created_at: 2025-04-17T11:16:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -133,6 +195,30 @@ const handleGetSessionError = async (
   }
 };
 
+const handleSendMessageToOutboundQueue = async (
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.ts
user: login: tmulberry-gds
body: Renamed it to handleSendErrorMessageToOutboundQueue
created_at: 2025-04-17T11:44:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -74,7 +83,60 @@ export async function lambdaHandlerConstructor(
   if (viewerKeyResult.isError) {
     throw new RetainMessageOnQueue("Failed to retrieve biometric viewer key");
   }
+  const viewerKey = viewerKeyResult.value;
+
+  const biometricSessionResult = await dependencies.getBiometricSession(
+    config.READID_BASE_URL,
+    sessionAttributes.sessionId,
+    viewerKey,
+  );
+
+  if (biometricSessionResult.isError) {
+    const eventService = dependencies.getEventService(config.TXMA_SQS);
+    const error: GetBiometricSessionError = biometricSessionResult.value;
+
+    // Check if the error was retryable based on error info
+    if (error.isRetryable) {
+      throw new RetainMessageOnQueue(
+        `Retryable error retrieving biometric session`,
+      );
+    }
+
+    handleSendMessageToOutboundQueue(dependencies, sessionAttributes, config);
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.ts
user: login: tmulberry-gds
body: added await, needed to move the log outside of the method for it to work properly
created_at: 2025-04-17T11:46:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,157 @@
+import { logger } from "../../common/logging/logger";
+import { LogMessage } from "../../common/logging/LogMessage";
+import {
+  HttpError,
+  ISendHttpRequest,
+  RetryConfig,
+  sendHttpRequest as sendHttpRequestDefault,
+  SuccessfulHttpResponse,
+} from "../../adapters/http/sendHttpRequest";
+import { Result, successResult, errorResult } from "../../utils/result";
+
+export interface BiometricSession {
+  id: string;
+  finish: string;
+}
+
+export interface BiometricSessionError {
+  statusCode?: number;
+  message: string;
+  isRetryable: boolean;
+}
+
+export type GetBiometricSession = (
+  baseUrl: string,
+  biometricSessionId: string,
+  viewerKey: string,
+  sendHttpRequest?: ISendHttpRequest,
+) => Promise<Result<BiometricSession, BiometricSessionError>>;
+
+export const getBiometricSession: GetBiometricSession = async (
+  baseUrl: string,
+  biometricSessionId: string,
+  viewerKey: string,
+  sendHttpRequest: ISendHttpRequest = sendHttpRequestDefault,
+) => {
+  const httpRequest = {
+    url: `${baseUrl}/odata/v1/ODataServlet/Sessions('${biometricSessionId}')`,
+    method: "GET" as const,
+    headers: {
+      "X-Innovalor-Authorization": viewerKey,
+      Accept: "application/json;odata.metadata=minimal",
+      "Content-Type": "application/json;odata.metadata=minimal",
+      "OData-MaxVersion": "4.0",
+      "OData-Version": "4.0",
+    },
+  };
+
+  const retryConfig: RetryConfig = {
+    retryableStatusCodes: [
+      429, 500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511,
+    ],
+    maxAttempts: 3,
+    delayInMillis: 50,
+  };
+
+  logger.debug(LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_ATTEMPT, {
+    data: {
+      biometricSessionId,
+    },
+  });
+
+  const getBiometricSessionResult: Result<SuccessfulHttpResponse, HttpError> =
+    await sendHttpRequest(httpRequest, retryConfig);
+
+  if (getBiometricSessionResult.isError) {
path: backend-api/src/functions/asyncIssueBiometricCredential/getBiometricSession/getBiometricSession.ts
user: login: tmulberry-gds
body: I'm weary to deviate too much with this file as it matches the structure of the getbiometricToken.ts file, thoughts?
created_at: 2025-04-17T11:47:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -311,6 +311,42 @@ export class LogMessage implements LogAttributes {
     "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_INVALID_CONFIG",
     "One or more required environment variables were missing or invalid.",
   );
+
+  static readonly ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE =
+    new LogMessage(
+      "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE",
+      "Failed to retrieve biometric session from ReadID",
+    );
+  static readonly ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_SUCCESS =
+    new LogMessage(
+      "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_SUCCESS",
+      "Successfully retrieved biometric session from ReadID",
+    );
+  static readonly ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_ATTEMPT =
+    new LogMessage(
+      "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_ATTEMPT",
+      "Retrieving biometric session from ReadID",
+    );
+  static readonly ISSUE_BIOMETRIC_CREDENTIAL_NOT_READY = new LogMessage(
path: backend-api/src/functions/common/logging/LogMessage.ts
user: login: chris-cooksley-gds
body: ```suggestion
  static readonly ISSUE_BIOMETRIC_CREDENTIAL_BIOMETRIC_SESSION_NOT_READY = new LogMessage(
```
created_at: 2025-04-17T14:17:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -311,6 +311,42 @@ export class LogMessage implements LogAttributes {
     "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_INVALID_CONFIG",
     "One or more required environment variables were missing or invalid.",
   );
+
+  static readonly ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE =
+    new LogMessage(
+      "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE",
+      "Failed to retrieve biometric session from ReadID",
+    );
+  static readonly ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_SUCCESS =
+    new LogMessage(
+      "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_SUCCESS",
+      "Successfully retrieved biometric session from ReadID",
+    );
+  static readonly ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_ATTEMPT =
+    new LogMessage(
+      "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_ATTEMPT",
+      "Retrieving biometric session from ReadID",
+    );
+  static readonly ISSUE_BIOMETRIC_CREDENTIAL_NOT_READY = new LogMessage(
+    "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_NOT_READY",
+    "Biometric session not ready (finish status not DONE)",
+  );
+
+  static readonly ISSUE_BIOMETRIC_CREDENTIAL_IPV_CORE_MESSAGE_ERROR =
+    new LogMessage(
+      "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_IPV_CORE_MESSAGE_ERROR",
+      "Error sending message to IPV Core",
+    );
+  static readonly ISSUE_BIOMETRIC_CREDENTIAL_RETRYABLE_ERROR = new LogMessage(
path: backend-api/src/functions/common/logging/LogMessage.ts
user: login: chris-cooksley-gds
body: Can these last two be deleted now?
created_at: 2025-04-17T14:18:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -311,6 +311,42 @@ export class LogMessage implements LogAttributes {
     "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_INVALID_CONFIG",
     "One or more required environment variables were missing or invalid.",
   );
+
+  static readonly ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE =
+    new LogMessage(
+      "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE",
+      "Failed to retrieve biometric session from ReadID",
+    );
+  static readonly ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_SUCCESS =
+    new LogMessage(
+      "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_SUCCESS",
+      "Successfully retrieved biometric session from ReadID",
+    );
+  static readonly ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_ATTEMPT =
+    new LogMessage(
+      "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_ATTEMPT",
+      "Retrieving biometric session from ReadID",
+    );
+  static readonly ISSUE_BIOMETRIC_CREDENTIAL_NOT_READY = new LogMessage(
path: backend-api/src/functions/common/logging/LogMessage.ts
user: login: tmulberry-gds
body: Comitted
created_at: 2025-04-17T14:19:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -311,6 +311,42 @@ export class LogMessage implements LogAttributes {
     "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_INVALID_CONFIG",
     "One or more required environment variables were missing or invalid.",
   );
+
+  static readonly ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE =
+    new LogMessage(
+      "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE",
+      "Failed to retrieve biometric session from ReadID",
+    );
+  static readonly ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_SUCCESS =
+    new LogMessage(
+      "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_SUCCESS",
+      "Successfully retrieved biometric session from ReadID",
+    );
+  static readonly ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_ATTEMPT =
+    new LogMessage(
+      "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_ATTEMPT",
+      "Retrieving biometric session from ReadID",
+    );
+  static readonly ISSUE_BIOMETRIC_CREDENTIAL_NOT_READY = new LogMessage(
+    "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_NOT_READY",
+    "Biometric session not ready (finish status not DONE)",
+  );
+
+  static readonly ISSUE_BIOMETRIC_CREDENTIAL_IPV_CORE_MESSAGE_ERROR =
+    new LogMessage(
+      "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_IPV_CORE_MESSAGE_ERROR",
+      "Error sending message to IPV Core",
+    );
+  static readonly ISSUE_BIOMETRIC_CREDENTIAL_RETRYABLE_ERROR = new LogMessage(
path: backend-api/src/functions/common/logging/LogMessage.ts
user: login: tmulberry-gds
body: Yeah, removed them
created_at: 2025-04-17T14:22:22Z
author_association: CONTRIBUTOR
path: backend-api/infra/functions/issueBiometricCredential.yaml
user: login: tmulberry-gds
body: Added the evidence on the ticket
created_at: 2025-04-17T14:28:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -105,7 +105,9 @@ Resources:
                   - sqs:DeleteMessage
                   - sqs:GetQueueAttributes
                   - sqs:ChangeMessageVisibility
-                Resource: !GetAtt VendorProcessingSQS.Arn
+                Resource: 
+                  - !GetAtt VendorProcessingSQS.Arn
+                  - !GetAtt IPVCoreSQS.Arn
path: backend-api/infra/functions/issueBiometricCredential.yaml
user: login: chris-cooksley-gds
body: I don't think this lambda needs to be able to perform these actions on the IPV core outbound queue (these are the actions you'd need to perform on a _source_ queue for a lambda). But the `AsyncAbortSessionFunctionIPVCoreSQSPolicy` should be a good model for what's needed to write to the outbound queue.
created_at: 2025-04-17T14:29:51Z
author_association: CONTRIBUTOR
path: backend-api/infra/functions/issueBiometricCredential.yaml
user: login: chris-cooksley-gds
body: Thanks for the evidence added to the ticket. Some followups:
* Could we get evidence of a _successful_ write of the error message to the IPV outbound queue? Based on the permissions below I assume this won't work yet, so would be good to confirm that it does once the permissions are updated.
* Out of curiosity - how were you able to simulate ReadID returning `$.finish == 'NOT_DONE'`, or a retryable error? Is that something we can just set up with the ReadID mock? (I haven't used it properly before)
created_at: 2025-04-17T14:42:27Z
author_association: CONTRIBUTOR
path: backend-api/infra/functions/issueBiometricCredential.yaml
user: login: chris-cooksley-gds
body: On that last point, I notice in the logs that the retryable errors seem to be coming _after_ a logged successful response from ReadID, which I wouldn't have thought was possible given the code in `getBiometricSession`. What's up with that? 
![image](https://github.com/user-attachments/assets/b4eff913-2147-4116-94eb-55a28a22b493)

created_at: 2025-04-17T14:48:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -105,7 +105,9 @@ Resources:
                   - sqs:DeleteMessage
                   - sqs:GetQueueAttributes
                   - sqs:ChangeMessageVisibility
-                Resource: !GetAtt VendorProcessingSQS.Arn
+                Resource: 
+                  - !GetAtt VendorProcessingSQS.Arn
+                  - !GetAtt IPVCoreSQS.Arn
path: backend-api/infra/functions/issueBiometricCredential.yaml
user: login: tmulberry-gds
body: Updated to match implementation of AsyncAbortSessionFunctionIPVCoreSQSPolicy
created_at: 2025-04-17T14:49:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,334 @@
+import { errorResult, Result, successResult } from "../../utils/result";
+import {
+  getBiometricSession,
+  BiometricSession,
+  GetBiometricSessionError,
+} from "./getBiometricSession";
+import { expect } from "@jest/globals";
+import "../../../../tests/testUtils/matchers";
+import { ISendHttpRequest } from "../../adapters/http/sendHttpRequest";
+
+describe("getBiometricSession", () => {
+  let result: Result<BiometricSession, GetBiometricSessionError>;
+  let consoleDebugSpy: jest.SpyInstance;
+  let consoleErrorSpy: jest.SpyInstance;
+  let mockSendHttpRequest: ISendHttpRequest;
+  const mockSessionId = "mockSessionId";
+
+  const expectedHttpRequest = {
+    url: "https://mockUrl.com/odata/v1/ODataServlet/Sessions('mockSessionId')",
+    method: "GET",
+    headers: {
+      "X-Innovalor-Authorization": "mockSubmitterKey",
+      Accept: "application/json;odata.metadata=minimal",
+      "Content-Type": "application/json;odata.metadata=minimal",
+      "OData-MaxVersion": "4.0",
+      "OData-Version": "4.0",
+    },
+  };
+
+  const expectedRetryConfig = {
+    retryableStatusCodes: [
+      429, 500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511,
+    ],
+    delayInMillis: 50,
+  };
+
+  beforeEach(() => {
+    consoleDebugSpy = jest.spyOn(console, "debug");
+    consoleErrorSpy = jest.spyOn(console, "error");
+
+    jest.resetModules();
+  });
+
+  describe("On every call", () => {
+    beforeEach(async () => {
+      mockSendHttpRequest = jest.fn().mockResolvedValue(
+        successResult({
+          statusCode: 200,
+          body: JSON.stringify({
+            id: mockSessionId,
path: backend-api/src/functions/asyncIssueBiometricCredential/getBiometricSession/getBiometricSession.test.ts
user: login: chris-cooksley-gds
body: Nitpick: can we remove the `id`s from these
created_at: 2025-04-17T14:53:56Z
author_association: CONTRIBUTOR
path: backend-api/infra/functions/issueBiometricCredential.yaml
user: login: tmulberry-gds
body: Yeah, I'll update the ticket now with the added evidence.

It was easy enough (albeit time consuming):
<img width="714" alt="image" src="https://github.com/user-attachments/assets/b5794c2e-c5c6-4f5a-b9bb-499126f156c6" />

I just went though every point I wanted to enter and set the check value to what I wanted and deployed, then after that worked, removed it and moved on to check other conditionals

created_at: 2025-04-17T14:54:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,334 @@
+import { errorResult, Result, successResult } from "../../utils/result";
+import {
+  getBiometricSession,
+  BiometricSession,
+  GetBiometricSessionError,
+} from "./getBiometricSession";
+import { expect } from "@jest/globals";
+import "../../../../tests/testUtils/matchers";
+import { ISendHttpRequest } from "../../adapters/http/sendHttpRequest";
+
+describe("getBiometricSession", () => {
+  let result: Result<BiometricSession, GetBiometricSessionError>;
+  let consoleDebugSpy: jest.SpyInstance;
+  let consoleErrorSpy: jest.SpyInstance;
+  let mockSendHttpRequest: ISendHttpRequest;
+  const mockSessionId = "mockSessionId";
path: backend-api/src/functions/asyncIssueBiometricCredential/getBiometricSession/getBiometricSession.test.ts
user: login: chris-cooksley-gds
body: ```suggestion
  const mockBiometricSessionId = "mockBiometricSessionId";
```
created_at: 2025-04-17T14:54:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,151 @@
+import { logger } from "../../common/logging/logger";
+import { LogMessage } from "../../common/logging/LogMessage";
+import {
+  HttpError,
+  ISendHttpRequest,
+  sendHttpRequest as sendHttpRequestDefault,
+  SuccessfulHttpResponse,
+} from "../../adapters/http/sendHttpRequest";
+import { Result, successResult, errorResult } from "../../utils/result";
+
+export interface BiometricSession {
+  finish: string;
+}
+
+export interface GetBiometricSessionError {
+  isRetryable: boolean;
+}
+
+export type GetBiometricSession = (
+  baseUrl: string,
+  biometricSessionId: string,
+  viewerKey: string,
+  sendHttpRequest?: ISendHttpRequest,
+) => Promise<Result<BiometricSession, GetBiometricSessionError>>;
+
+export const getBiometricSession: GetBiometricSession = async (
+  baseUrl: string,
+  biometricSessionId: string,
+  viewerKey: string,
+  sendHttpRequest: ISendHttpRequest = sendHttpRequestDefault,
+) => {
+  const httpRequest = {
+    url: `${baseUrl}/odata/v1/ODataServlet/Sessions('${biometricSessionId}')`,
+    method: "GET" as const,
+    headers: {
+      "X-Innovalor-Authorization": viewerKey,
+      Accept: "application/json;odata.metadata=minimal",
+      "Content-Type": "application/json;odata.metadata=minimal",
+      "OData-MaxVersion": "4.0",
+      "OData-Version": "4.0",
+    },
+  };
+
+  const retryConfig = {
+    retryableStatusCodes: [
+      429, 500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511,
+    ],
+    delayInMillis: 50,
+  };
+
+  logger.debug(LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_ATTEMPT, {
+    data: {
+      biometricSessionId,
+    },
+  });
+
+  const getBiometricSessionResult: Result<SuccessfulHttpResponse, HttpError> =
+    await sendHttpRequest(httpRequest, retryConfig);
+
+  if (getBiometricSessionResult.isError) {
+    const error = getBiometricSessionResult.value;
+    const statusCode = error.statusCode ? Number(error.statusCode) : undefined;
+    const retryableStatusCodes = retryConfig.retryableStatusCodes;
+
+    const isRetryable =
+      !statusCode || retryableStatusCodes.includes(statusCode);
+
+    logger.error(
+      LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE,
+      {
+        data: {
+          error,
+          isRetryable,
+          biometricSessionId,
+        },
+      },
+    );
+
+    return errorResult({
+      statusCode,
+      message: "Failed to retrieve biometric session",
+      isRetryable,
+    });
+  }
+
+  const getBiometricSessionResponse = getBiometricSessionResult.value;
+
+  if (getBiometricSessionResponse?.body == null) {
+    logger.error(
+      LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE,
+      {
+        data: {
+          getBiometricSessionResponse,
+          biometricSessionId,
+        },
+      },
+    );
+
+    return errorResult({
+      message: "Empty response body from ReadID",
path: backend-api/src/functions/asyncIssueBiometricCredential/getBiometricSession/getBiometricSession.ts
user: login: chris-cooksley-gds
body: These `message`s aren't in `GetBiometricSessionError`, and aren't needed - can we remove them? 
created_at: 2025-04-17T14:57:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,334 @@
+import { errorResult, Result, successResult } from "../../utils/result";
+import {
+  getBiometricSession,
+  BiometricSession,
+  GetBiometricSessionError,
+} from "./getBiometricSession";
+import { expect } from "@jest/globals";
+import "../../../../tests/testUtils/matchers";
+import { ISendHttpRequest } from "../../adapters/http/sendHttpRequest";
+
+describe("getBiometricSession", () => {
+  let result: Result<BiometricSession, GetBiometricSessionError>;
+  let consoleDebugSpy: jest.SpyInstance;
+  let consoleErrorSpy: jest.SpyInstance;
+  let mockSendHttpRequest: ISendHttpRequest;
+  const mockSessionId = "mockSessionId";
path: backend-api/src/functions/asyncIssueBiometricCredential/getBiometricSession/getBiometricSession.test.ts
user: login: tmulberry-gds
body: Updated
created_at: 2025-04-17T14:58:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,151 @@
+import { logger } from "../../common/logging/logger";
+import { LogMessage } from "../../common/logging/LogMessage";
+import {
+  HttpError,
+  ISendHttpRequest,
+  sendHttpRequest as sendHttpRequestDefault,
+  SuccessfulHttpResponse,
+} from "../../adapters/http/sendHttpRequest";
+import { Result, successResult, errorResult } from "../../utils/result";
+
+export interface BiometricSession {
+  finish: string;
+}
+
+export interface GetBiometricSessionError {
+  isRetryable: boolean;
+}
+
+export type GetBiometricSession = (
+  baseUrl: string,
+  biometricSessionId: string,
+  viewerKey: string,
+  sendHttpRequest?: ISendHttpRequest,
+) => Promise<Result<BiometricSession, GetBiometricSessionError>>;
+
+export const getBiometricSession: GetBiometricSession = async (
+  baseUrl: string,
+  biometricSessionId: string,
+  viewerKey: string,
+  sendHttpRequest: ISendHttpRequest = sendHttpRequestDefault,
+) => {
+  const httpRequest = {
+    url: `${baseUrl}/odata/v1/ODataServlet/Sessions('${biometricSessionId}')`,
+    method: "GET" as const,
+    headers: {
+      "X-Innovalor-Authorization": viewerKey,
+      Accept: "application/json;odata.metadata=minimal",
+      "Content-Type": "application/json;odata.metadata=minimal",
+      "OData-MaxVersion": "4.0",
+      "OData-Version": "4.0",
+    },
+  };
+
+  const retryConfig = {
+    retryableStatusCodes: [
+      429, 500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511,
+    ],
+    delayInMillis: 50,
+  };
+
+  logger.debug(LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_ATTEMPT, {
+    data: {
+      biometricSessionId,
+    },
+  });
+
+  const getBiometricSessionResult: Result<SuccessfulHttpResponse, HttpError> =
+    await sendHttpRequest(httpRequest, retryConfig);
+
+  if (getBiometricSessionResult.isError) {
+    const error = getBiometricSessionResult.value;
+    const statusCode = error.statusCode ? Number(error.statusCode) : undefined;
+    const retryableStatusCodes = retryConfig.retryableStatusCodes;
+
+    const isRetryable =
+      !statusCode || retryableStatusCodes.includes(statusCode);
+
+    logger.error(
+      LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE,
+      {
+        data: {
+          error,
+          isRetryable,
+          biometricSessionId,
+        },
+      },
+    );
+
+    return errorResult({
+      statusCode,
+      message: "Failed to retrieve biometric session",
+      isRetryable,
+    });
+  }
+
+  const getBiometricSessionResponse = getBiometricSessionResult.value;
+
+  if (getBiometricSessionResponse?.body == null) {
+    logger.error(
+      LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE,
+      {
+        data: {
+          getBiometricSessionResponse,
+          biometricSessionId,
+        },
+      },
+    );
+
+    return errorResult({
+      message: "Empty response body from ReadID",
path: backend-api/src/functions/asyncIssueBiometricCredential/getBiometricSession/getBiometricSession.ts
user: login: tmulberry-gds
body: Removed
created_at: 2025-04-17T15:00:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,151 @@
+import { logger } from "../../common/logging/logger";
+import { LogMessage } from "../../common/logging/LogMessage";
+import {
+  HttpError,
+  ISendHttpRequest,
+  sendHttpRequest as sendHttpRequestDefault,
+  SuccessfulHttpResponse,
+} from "../../adapters/http/sendHttpRequest";
+import { Result, successResult, errorResult } from "../../utils/result";
+
+export interface BiometricSession {
+  finish: string;
+}
+
+export interface GetBiometricSessionError {
+  isRetryable: boolean;
+}
+
+export type GetBiometricSession = (
+  baseUrl: string,
+  biometricSessionId: string,
+  viewerKey: string,
+  sendHttpRequest?: ISendHttpRequest,
+) => Promise<Result<BiometricSession, GetBiometricSessionError>>;
+
+export const getBiometricSession: GetBiometricSession = async (
+  baseUrl: string,
+  biometricSessionId: string,
+  viewerKey: string,
+  sendHttpRequest: ISendHttpRequest = sendHttpRequestDefault,
+) => {
+  const httpRequest = {
+    url: `${baseUrl}/odata/v1/ODataServlet/Sessions('${biometricSessionId}')`,
+    method: "GET" as const,
+    headers: {
+      "X-Innovalor-Authorization": viewerKey,
+      Accept: "application/json;odata.metadata=minimal",
+      "Content-Type": "application/json;odata.metadata=minimal",
+      "OData-MaxVersion": "4.0",
+      "OData-Version": "4.0",
+    },
+  };
+
+  const retryConfig = {
+    retryableStatusCodes: [
+      429, 500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511,
+    ],
+    delayInMillis: 50,
+  };
+
+  logger.debug(LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_ATTEMPT, {
+    data: {
+      biometricSessionId,
+    },
+  });
+
+  const getBiometricSessionResult: Result<SuccessfulHttpResponse, HttpError> =
+    await sendHttpRequest(httpRequest, retryConfig);
+
+  if (getBiometricSessionResult.isError) {
+    const error = getBiometricSessionResult.value;
+    const statusCode = error.statusCode ? Number(error.statusCode) : undefined;
+    const retryableStatusCodes = retryConfig.retryableStatusCodes;
+
+    const isRetryable =
+      !statusCode || retryableStatusCodes.includes(statusCode);
+
+    logger.error(
+      LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE,
+      {
+        data: {
+          error,
+          isRetryable,
+          biometricSessionId,
+        },
+      },
+    );
+
+    return errorResult({
+      statusCode,
path: backend-api/src/functions/asyncIssueBiometricCredential/getBiometricSession/getBiometricSession.ts
user: login: chris-cooksley-gds
body: Still returning the statusCode here, which should no longer be needed.
created_at: 2025-04-17T15:00:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,334 @@
+import { errorResult, Result, successResult } from "../../utils/result";
+import {
+  getBiometricSession,
+  BiometricSession,
+  GetBiometricSessionError,
+} from "./getBiometricSession";
+import { expect } from "@jest/globals";
+import "../../../../tests/testUtils/matchers";
+import { ISendHttpRequest } from "../../adapters/http/sendHttpRequest";
+
+describe("getBiometricSession", () => {
+  let result: Result<BiometricSession, GetBiometricSessionError>;
+  let consoleDebugSpy: jest.SpyInstance;
+  let consoleErrorSpy: jest.SpyInstance;
+  let mockSendHttpRequest: ISendHttpRequest;
+  const mockSessionId = "mockSessionId";
+
+  const expectedHttpRequest = {
+    url: "https://mockUrl.com/odata/v1/ODataServlet/Sessions('mockSessionId')",
+    method: "GET",
+    headers: {
+      "X-Innovalor-Authorization": "mockSubmitterKey",
+      Accept: "application/json;odata.metadata=minimal",
+      "Content-Type": "application/json;odata.metadata=minimal",
+      "OData-MaxVersion": "4.0",
+      "OData-Version": "4.0",
+    },
+  };
+
+  const expectedRetryConfig = {
+    retryableStatusCodes: [
+      429, 500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511,
+    ],
+    delayInMillis: 50,
+  };
+
+  beforeEach(() => {
+    consoleDebugSpy = jest.spyOn(console, "debug");
+    consoleErrorSpy = jest.spyOn(console, "error");
+
+    jest.resetModules();
+  });
+
+  describe("On every call", () => {
+    beforeEach(async () => {
+      mockSendHttpRequest = jest.fn().mockResolvedValue(
+        successResult({
+          statusCode: 200,
+          body: JSON.stringify({
+            id: mockSessionId,
+            finish: "DONE",
+          }),
+          headers: {
+            mockHeaderKey: "mockHeaderValue",
+          },
+        }),
+      );
+
+      result = await getBiometricSession(
+        "https://mockUrl.com",
+        mockSessionId,
+        "mockSubmitterKey",
+        mockSendHttpRequest,
+      );
+    });
+
+    it("Logs network call attempt at debug level", () => {
+      expect(consoleDebugSpy).toHaveBeenCalledWithLogFields({
+        messageCode:
+          "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_ATTEMPT",
+      });
+    });
+  });
+
+  describe("Given an error is returned when requesting session", () => {
+    describe("Given a retryable error (5XX)", () => {
+      beforeEach(async () => {
+        mockSendHttpRequest = jest.fn().mockResolvedValue(
+          errorResult({
+            statusCode: 503,
+            message: "Service Unavailable",
+          }),
+        );
+        result = await getBiometricSession(
+          "https://mockUrl.com",
+          mockSessionId,
+          "mockSubmitterKey",
+          mockSendHttpRequest,
+        );
+      });
+
+      it("Logs error", () => {
+        expect(consoleErrorSpy).toHaveBeenCalledWithLogFields({
+          messageCode:
+            "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE",
+        });
+      });
+
+      it("Returns an error result with retryable flag", () => {
+        expect(result.isError).toBe(true);
+        expect(result.value).toEqual({
+          statusCode: 503,
+          message: "Failed to retrieve biometric session",
+          isRetryable: true,
+        });
+        expect(mockSendHttpRequest).toBeCalledWith(
+          expectedHttpRequest,
+          expectedRetryConfig,
+        );
+      });
+    });
+
+    describe("Given a non-retryable error (4XX)", () => {
+      beforeEach(async () => {
+        mockSendHttpRequest = jest.fn().mockResolvedValue(
+          errorResult({
+            statusCode: 404,
+            message: "Not Found",
+          }),
+        );
+        result = await getBiometricSession(
+          "https://mockUrl.com",
+          mockSessionId,
+          "mockSubmitterKey",
+          mockSendHttpRequest,
+        );
+      });
+
+      it("Logs error", () => {
+        expect(consoleErrorSpy).toHaveBeenCalledWithLogFields({
+          messageCode:
+            "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE",
+        });
+      });
+
+      it("Returns an error result with non-retryable flag", () => {
+        expect(result.isError).toBe(true);
+        expect(result.value).toEqual({
+          statusCode: 404,
+          message: "Failed to retrieve biometric session",
+          isRetryable: false,
+        });
+        expect(mockSendHttpRequest).toBeCalledWith(
+          expectedHttpRequest,
+          expectedRetryConfig,
+        );
+      });
+    });
+
+    describe("Given a network error (no status code)", () => {
+      beforeEach(async () => {
+        mockSendHttpRequest = jest.fn().mockResolvedValue(
+          errorResult({
+            message: "Network Error",
+          }),
+        );
+        result = await getBiometricSession(
+          "https://mockUrl.com",
+          mockSessionId,
+          "mockSubmitterKey",
+          mockSendHttpRequest,
+        );
+      });
+
+      it("Treats it as retryable", () => {
+        expect(result.isError).toBe(true);
+        expect(result.value).toEqual({
+          statusCode: undefined,
+          message: "Failed to retrieve biometric session",
+          isRetryable: true,
+        });
+      });
+    });
+  });
+
+  describe("Given the response is invalid", () => {
+    describe("Given response body is undefined", () => {
+      beforeEach(async () => {
+        mockSendHttpRequest = jest.fn().mockResolvedValue(
+          successResult({
+            statusCode: 200,
+            body: undefined,
+            headers: {
+              mockHeaderKey: "mockHeaderValue",
+            },
+          }),
+        );
+
+        result = await getBiometricSession(
+          "https://mockUrl.com",
+          mockSessionId,
+          "mockSubmitterKey",
+          mockSendHttpRequest,
+        );
+      });
+
+      it("Logs error", () => {
+        expect(consoleErrorSpy).toHaveBeenCalledWithLogFields({
+          messageCode:
+            "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE",
+        });
+      });
+
+      it("Returns an error result with retryable flag", () => {
path: backend-api/src/functions/asyncIssueBiometricCredential/getBiometricSession/getBiometricSession.test.ts
user: login: chris-cooksley-gds
body: ```suggestion
      it("Returns an error result with retryable flag set to false", () => {
```
created_at: 2025-04-17T15:01:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,334 @@
+import { errorResult, Result, successResult } from "../../utils/result";
+import {
+  getBiometricSession,
+  BiometricSession,
+  GetBiometricSessionError,
+} from "./getBiometricSession";
+import { expect } from "@jest/globals";
+import "../../../../tests/testUtils/matchers";
+import { ISendHttpRequest } from "../../adapters/http/sendHttpRequest";
+
+describe("getBiometricSession", () => {
+  let result: Result<BiometricSession, GetBiometricSessionError>;
+  let consoleDebugSpy: jest.SpyInstance;
+  let consoleErrorSpy: jest.SpyInstance;
+  let mockSendHttpRequest: ISendHttpRequest;
+  const mockSessionId = "mockSessionId";
+
+  const expectedHttpRequest = {
+    url: "https://mockUrl.com/odata/v1/ODataServlet/Sessions('mockSessionId')",
+    method: "GET",
+    headers: {
+      "X-Innovalor-Authorization": "mockSubmitterKey",
+      Accept: "application/json;odata.metadata=minimal",
+      "Content-Type": "application/json;odata.metadata=minimal",
+      "OData-MaxVersion": "4.0",
+      "OData-Version": "4.0",
+    },
+  };
+
+  const expectedRetryConfig = {
+    retryableStatusCodes: [
+      429, 500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511,
+    ],
+    delayInMillis: 50,
+  };
+
+  beforeEach(() => {
+    consoleDebugSpy = jest.spyOn(console, "debug");
+    consoleErrorSpy = jest.spyOn(console, "error");
+
+    jest.resetModules();
+  });
+
+  describe("On every call", () => {
+    beforeEach(async () => {
+      mockSendHttpRequest = jest.fn().mockResolvedValue(
+        successResult({
+          statusCode: 200,
+          body: JSON.stringify({
+            id: mockSessionId,
+            finish: "DONE",
+          }),
+          headers: {
+            mockHeaderKey: "mockHeaderValue",
+          },
+        }),
+      );
+
+      result = await getBiometricSession(
+        "https://mockUrl.com",
+        mockSessionId,
+        "mockSubmitterKey",
+        mockSendHttpRequest,
+      );
+    });
+
+    it("Logs network call attempt at debug level", () => {
+      expect(consoleDebugSpy).toHaveBeenCalledWithLogFields({
+        messageCode:
+          "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_ATTEMPT",
+      });
+    });
+  });
+
+  describe("Given an error is returned when requesting session", () => {
+    describe("Given a retryable error (5XX)", () => {
+      beforeEach(async () => {
+        mockSendHttpRequest = jest.fn().mockResolvedValue(
+          errorResult({
+            statusCode: 503,
+            message: "Service Unavailable",
+          }),
+        );
+        result = await getBiometricSession(
+          "https://mockUrl.com",
+          mockSessionId,
+          "mockSubmitterKey",
+          mockSendHttpRequest,
+        );
+      });
+
+      it("Logs error", () => {
+        expect(consoleErrorSpy).toHaveBeenCalledWithLogFields({
+          messageCode:
+            "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE",
+        });
+      });
+
+      it("Returns an error result with retryable flag", () => {
+        expect(result.isError).toBe(true);
+        expect(result.value).toEqual({
+          statusCode: 503,
+          message: "Failed to retrieve biometric session",
+          isRetryable: true,
+        });
+        expect(mockSendHttpRequest).toBeCalledWith(
+          expectedHttpRequest,
+          expectedRetryConfig,
+        );
+      });
+    });
+
+    describe("Given a non-retryable error (4XX)", () => {
+      beforeEach(async () => {
+        mockSendHttpRequest = jest.fn().mockResolvedValue(
+          errorResult({
+            statusCode: 404,
+            message: "Not Found",
+          }),
+        );
+        result = await getBiometricSession(
+          "https://mockUrl.com",
+          mockSessionId,
+          "mockSubmitterKey",
+          mockSendHttpRequest,
+        );
+      });
+
+      it("Logs error", () => {
+        expect(consoleErrorSpy).toHaveBeenCalledWithLogFields({
+          messageCode:
+            "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE",
+        });
+      });
+
+      it("Returns an error result with non-retryable flag", () => {
+        expect(result.isError).toBe(true);
+        expect(result.value).toEqual({
+          statusCode: 404,
+          message: "Failed to retrieve biometric session",
+          isRetryable: false,
+        });
+        expect(mockSendHttpRequest).toBeCalledWith(
+          expectedHttpRequest,
+          expectedRetryConfig,
+        );
+      });
+    });
+
+    describe("Given a network error (no status code)", () => {
+      beforeEach(async () => {
+        mockSendHttpRequest = jest.fn().mockResolvedValue(
+          errorResult({
+            message: "Network Error",
+          }),
+        );
+        result = await getBiometricSession(
+          "https://mockUrl.com",
+          mockSessionId,
+          "mockSubmitterKey",
+          mockSendHttpRequest,
+        );
+      });
+
+      it("Treats it as retryable", () => {
+        expect(result.isError).toBe(true);
+        expect(result.value).toEqual({
+          statusCode: undefined,
+          message: "Failed to retrieve biometric session",
+          isRetryable: true,
+        });
+      });
+    });
+  });
+
+  describe("Given the response is invalid", () => {
+    describe("Given response body is undefined", () => {
+      beforeEach(async () => {
+        mockSendHttpRequest = jest.fn().mockResolvedValue(
+          successResult({
+            statusCode: 200,
+            body: undefined,
+            headers: {
+              mockHeaderKey: "mockHeaderValue",
+            },
+          }),
+        );
+
+        result = await getBiometricSession(
+          "https://mockUrl.com",
+          mockSessionId,
+          "mockSubmitterKey",
+          mockSendHttpRequest,
+        );
+      });
+
+      it("Logs error", () => {
+        expect(consoleErrorSpy).toHaveBeenCalledWithLogFields({
+          messageCode:
+            "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE",
+        });
+      });
+
+      it("Returns an error result with retryable flag", () => {
+        expect(result.isError).toBe(true);
+        expect(result.value).toEqual({
+          message: "Empty response body from ReadID",
+          isRetryable: false,
+        });
+        expect(mockSendHttpRequest).toBeCalledWith(
+          expectedHttpRequest,
+          expectedRetryConfig,
+        );
+      });
+    });
+
+    describe("Given response body cannot be parsed", () => {
+      beforeEach(async () => {
+        mockSendHttpRequest = jest.fn().mockResolvedValue(
+          successResult({
+            statusCode: 200,
+            body: "Invalid JSON",
+            headers: {
+              mockHeaderKey: "mockHeaderValue",
+            },
+          }),
+        );
+
+        result = await getBiometricSession(
+          "https://mockUrl.com",
+          mockSessionId,
+          "mockSubmitterKey",
+          mockSendHttpRequest,
+        );
+      });
+
+      it("Logs error", () => {
+        expect(consoleErrorSpy).toHaveBeenCalledWithLogFields({
+          messageCode:
+            "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE",
+        });
+      });
+
+      it("Returns an error result with retryable flag", () => {
path: backend-api/src/functions/asyncIssueBiometricCredential/getBiometricSession/getBiometricSession.test.ts
user: login: chris-cooksley-gds
body: ```suggestion
      it("Returns an error result with retryable flag set to false", () => {
```
created_at: 2025-04-17T15:01:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,334 @@
+import { errorResult, Result, successResult } from "../../utils/result";
+import {
+  getBiometricSession,
+  BiometricSession,
+  GetBiometricSessionError,
+} from "./getBiometricSession";
+import { expect } from "@jest/globals";
+import "../../../../tests/testUtils/matchers";
+import { ISendHttpRequest } from "../../adapters/http/sendHttpRequest";
+
+describe("getBiometricSession", () => {
+  let result: Result<BiometricSession, GetBiometricSessionError>;
+  let consoleDebugSpy: jest.SpyInstance;
+  let consoleErrorSpy: jest.SpyInstance;
+  let mockSendHttpRequest: ISendHttpRequest;
+  const mockSessionId = "mockSessionId";
+
+  const expectedHttpRequest = {
+    url: "https://mockUrl.com/odata/v1/ODataServlet/Sessions('mockSessionId')",
+    method: "GET",
+    headers: {
+      "X-Innovalor-Authorization": "mockSubmitterKey",
+      Accept: "application/json;odata.metadata=minimal",
+      "Content-Type": "application/json;odata.metadata=minimal",
+      "OData-MaxVersion": "4.0",
+      "OData-Version": "4.0",
+    },
+  };
+
+  const expectedRetryConfig = {
+    retryableStatusCodes: [
+      429, 500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511,
+    ],
+    delayInMillis: 50,
+  };
+
+  beforeEach(() => {
+    consoleDebugSpy = jest.spyOn(console, "debug");
+    consoleErrorSpy = jest.spyOn(console, "error");
+
+    jest.resetModules();
+  });
+
+  describe("On every call", () => {
+    beforeEach(async () => {
+      mockSendHttpRequest = jest.fn().mockResolvedValue(
+        successResult({
+          statusCode: 200,
+          body: JSON.stringify({
+            id: mockSessionId,
+            finish: "DONE",
+          }),
+          headers: {
+            mockHeaderKey: "mockHeaderValue",
+          },
+        }),
+      );
+
+      result = await getBiometricSession(
+        "https://mockUrl.com",
+        mockSessionId,
+        "mockSubmitterKey",
+        mockSendHttpRequest,
+      );
+    });
+
+    it("Logs network call attempt at debug level", () => {
+      expect(consoleDebugSpy).toHaveBeenCalledWithLogFields({
+        messageCode:
+          "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_ATTEMPT",
+      });
+    });
+  });
+
+  describe("Given an error is returned when requesting session", () => {
+    describe("Given a retryable error (5XX)", () => {
+      beforeEach(async () => {
+        mockSendHttpRequest = jest.fn().mockResolvedValue(
+          errorResult({
+            statusCode: 503,
+            message: "Service Unavailable",
+          }),
+        );
+        result = await getBiometricSession(
+          "https://mockUrl.com",
+          mockSessionId,
+          "mockSubmitterKey",
+          mockSendHttpRequest,
+        );
+      });
+
+      it("Logs error", () => {
+        expect(consoleErrorSpy).toHaveBeenCalledWithLogFields({
+          messageCode:
+            "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE",
+        });
+      });
+
+      it("Returns an error result with retryable flag", () => {
+        expect(result.isError).toBe(true);
+        expect(result.value).toEqual({
+          statusCode: 503,
+          message: "Failed to retrieve biometric session",
+          isRetryable: true,
+        });
+        expect(mockSendHttpRequest).toBeCalledWith(
+          expectedHttpRequest,
+          expectedRetryConfig,
+        );
+      });
+    });
+
+    describe("Given a non-retryable error (4XX)", () => {
+      beforeEach(async () => {
+        mockSendHttpRequest = jest.fn().mockResolvedValue(
+          errorResult({
+            statusCode: 404,
+            message: "Not Found",
+          }),
+        );
+        result = await getBiometricSession(
+          "https://mockUrl.com",
+          mockSessionId,
+          "mockSubmitterKey",
+          mockSendHttpRequest,
+        );
+      });
+
+      it("Logs error", () => {
+        expect(consoleErrorSpy).toHaveBeenCalledWithLogFields({
+          messageCode:
+            "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE",
+        });
+      });
+
+      it("Returns an error result with non-retryable flag", () => {
+        expect(result.isError).toBe(true);
+        expect(result.value).toEqual({
+          statusCode: 404,
+          message: "Failed to retrieve biometric session",
+          isRetryable: false,
+        });
+        expect(mockSendHttpRequest).toBeCalledWith(
+          expectedHttpRequest,
+          expectedRetryConfig,
+        );
+      });
+    });
+
+    describe("Given a network error (no status code)", () => {
+      beforeEach(async () => {
+        mockSendHttpRequest = jest.fn().mockResolvedValue(
+          errorResult({
+            message: "Network Error",
+          }),
+        );
+        result = await getBiometricSession(
+          "https://mockUrl.com",
+          mockSessionId,
+          "mockSubmitterKey",
+          mockSendHttpRequest,
+        );
+      });
+
+      it("Treats it as retryable", () => {
+        expect(result.isError).toBe(true);
+        expect(result.value).toEqual({
+          statusCode: undefined,
+          message: "Failed to retrieve biometric session",
+          isRetryable: true,
+        });
+      });
+    });
+  });
+
+  describe("Given the response is invalid", () => {
+    describe("Given response body is undefined", () => {
+      beforeEach(async () => {
+        mockSendHttpRequest = jest.fn().mockResolvedValue(
+          successResult({
+            statusCode: 200,
+            body: undefined,
+            headers: {
+              mockHeaderKey: "mockHeaderValue",
+            },
+          }),
+        );
+
+        result = await getBiometricSession(
+          "https://mockUrl.com",
+          mockSessionId,
+          "mockSubmitterKey",
+          mockSendHttpRequest,
+        );
+      });
+
+      it("Logs error", () => {
+        expect(consoleErrorSpy).toHaveBeenCalledWithLogFields({
+          messageCode:
+            "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE",
+        });
+      });
+
+      it("Returns an error result with retryable flag", () => {
+        expect(result.isError).toBe(true);
+        expect(result.value).toEqual({
+          message: "Empty response body from ReadID",
+          isRetryable: false,
+        });
+        expect(mockSendHttpRequest).toBeCalledWith(
+          expectedHttpRequest,
+          expectedRetryConfig,
+        );
+      });
+    });
+
+    describe("Given response body cannot be parsed", () => {
+      beforeEach(async () => {
+        mockSendHttpRequest = jest.fn().mockResolvedValue(
+          successResult({
+            statusCode: 200,
+            body: "Invalid JSON",
+            headers: {
+              mockHeaderKey: "mockHeaderValue",
+            },
+          }),
+        );
+
+        result = await getBiometricSession(
+          "https://mockUrl.com",
+          mockSessionId,
+          "mockSubmitterKey",
+          mockSendHttpRequest,
+        );
+      });
+
+      it("Logs error", () => {
+        expect(consoleErrorSpy).toHaveBeenCalledWithLogFields({
+          messageCode:
+            "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE",
+        });
+      });
+
+      it("Returns an error result with retryable flag", () => {
+        expect(result.isError).toBe(true);
+        expect(result.value).toEqual({
+          message: "Failed to parse response JSON",
+          isRetryable: false,
+        });
+        expect(mockSendHttpRequest).toBeCalledWith(
+          expectedHttpRequest,
+          expectedRetryConfig,
+        );
+      });
+    });
+
+    describe("Given response has invalid structure", () => {
+      beforeEach(async () => {
+        mockSendHttpRequest = jest.fn().mockResolvedValue(
+          successResult({
+            statusCode: 200,
+            body: JSON.stringify({ id: mockSessionId, status: "COMPLETE" }),
+            headers: {
+              mockHeaderKey: "mockHeaderValue",
+            },
+          }),
+        );
+
+        result = await getBiometricSession(
+          "https://mockUrl.com",
+          mockSessionId,
+          "mockSubmitterKey",
+          mockSendHttpRequest,
+        );
+      });
+
+      it("Logs error", () => {
+        expect(consoleErrorSpy).toHaveBeenCalledWithLogFields({
+          messageCode:
+            "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE",
+        });
+      });
+
+      it("Returns an error result with non-retryable flag", () => {
+        expect(result.isError).toBe(true);
+        expect(result.value).toEqual({
+          message: "Invalid response structure from ReadID",
+          isRetryable: false,
+        });
+      });
+    });
+  });
+
+  describe("Given valid request is made", () => {
+    const mockSession = {
+      id: mockSessionId,
+      finish: "DONE",
+    };
+
+    beforeEach(async () => {
+      mockSendHttpRequest = jest.fn().mockResolvedValue(
+        successResult({
+          statusCode: 200,
+          body: JSON.stringify(mockSession),
+          headers: {
+            mockHeaderKey: "mockHeaderValue",
+          },
+        }),
+      );
+
+      result = await getBiometricSession(
+        "https://mockUrl.com",
+        mockSessionId,
+        "mockSubmitterKey",
+        mockSendHttpRequest,
+      );
+    });
+
+    it("Logs success at debug level", () => {
+      expect(consoleDebugSpy).toHaveBeenCalledWithLogFields({
+        messageCode:
+          "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_SUCCESS",
+      });
+    });
+
+    it("Returns successResult containing biometric session", () => {
+      expect(result).toEqual(successResult(mockSession));
+      expect(mockSendHttpRequest).toBeCalledWith(
path: backend-api/src/functions/asyncIssueBiometricCredential/getBiometricSession/getBiometricSession.test.ts
user: login: chris-cooksley-gds
body: Nitpick: can be its own `it` block as it's an unrelated assertion.
created_at: 2025-04-17T15:02:35Z
author_association: CONTRIBUTOR
path: backend-api/infra/functions/issueBiometricCredential.yaml
user: login: tmulberry-gds
body: Re: your image, its just how I triggered the errors. I didnt fail getBiometricSession, so it still runs successfully, I just fail the point in the handler that logs the error, so it looks as if it passed and failed
created_at: 2025-04-17T15:04:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -370,6 +414,104 @@ describe("Async Issue Biometric Credential", () => {
     });
   });
 
+  describe("When biometric session is not ready", () => {
+    beforeEach(async () => {
+      dependencies.getBiometricSession = mockGetBiometricSessionNotReady;
+      try {
+        await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
+      } catch (error: unknown) {
+        lambdaError = error;
+      }
+    });
+
+    it("Logs the appropriate message", () => {
+      expect(consoleInfoSpy).toHaveBeenCalledWithLogFields({
+        messageCode: "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_NOT_READY",
+        data: {
+          finish: "PROCESSING",
+        },
+      });
+    });
+
+    it("Throws RetainMessageOnQueue with appropriate message", () => {
+      expect(lambdaError).toBeInstanceOf(RetainMessageOnQueue);
+      expect((lambdaError as RetainMessageOnQueue).message).toMatch(
+        /Biometric session not ready: PROCESSING/,
+      );
+    });
+  });
+
+  describe("When biometric session retrieval fails", () => {
+    describe("With retryable error", () => {
+      beforeEach(async () => {
+        dependencies.getBiometricSession =
+          mockGetBiometricSessionRetryableFailure;
+        try {
+          await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
+        } catch (error: unknown) {
+          lambdaError = error;
+        }
+      });
+
+      it("Throws RetainMessageOnQueue with appropriate message", () => {
+        expect(lambdaError).toBeInstanceOf(RetainMessageOnQueue);
+        expect((lambdaError as RetainMessageOnQueue).message).toMatch(
+          /Retryable error/,
+        );
+      });
+    });
+
+    describe("With non-retryable error", () => {
+      beforeEach(async () => {
+        dependencies.getBiometricSession =
+          mockGetBiometricSessionNonRetryableFailure;
+        await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
+      });
+
+      it("Sends error to IPV Core", () => {
+        expect(mockSuccessfulSendMessageToSqs).toHaveBeenCalledWith(
+          "mockIpvcoreOutboundSqs",
+          {
+            sub: "mockSubjectIdentifier",
+            state: "mockClientState",
+            error: "server_error",
+            error_description: "Internal server error",
+          },
+        );
+      });
+
+      it("Sends event to TxMA", () => {
+        expect(mockWriteGenericEventSuccessResult).toHaveBeenCalledWith(
+          expect.objectContaining({
+            eventName: "DCMAW_ASYNC_CRI_5XXERROR",
+            componentId: "mockIssuer",
+          }),
+        );
+      });
+
+      describe("When sending error to IPV Core fails", () => {
+        beforeEach(async () => {
+          dependencies.sendMessageToSqs = jest
+            .fn()
+            .mockResolvedValue(emptyFailure());
+          dependencies.getBiometricSession =
+            mockGetBiometricSessionNonRetryableFailure;
+          await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
+        });
+
+        it("Logs IPV Core message error", () => {
+          expect(consoleErrorSpy).toHaveBeenCalledWithLogFields({
+            messageCode:
+              "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_IPV_CORE_MESSAGE_ERROR",
+          });
+        });
+
+        it("Still sends event to TxMA", () => {
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.test.ts
user: login: chris-cooksley-gds
body: Appreciate it's getting a bit nested - but what if that call to TxMA then fails?
created_at: 2025-04-17T15:05:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -370,6 +414,104 @@ describe("Async Issue Biometric Credential", () => {
     });
   });
 
+  describe("When biometric session is not ready", () => {
+    beforeEach(async () => {
+      dependencies.getBiometricSession = mockGetBiometricSessionNotReady;
+      try {
+        await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
+      } catch (error: unknown) {
+        lambdaError = error;
+      }
+    });
+
+    it("Logs the appropriate message", () => {
+      expect(consoleInfoSpy).toHaveBeenCalledWithLogFields({
+        messageCode: "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_NOT_READY",
+        data: {
+          finish: "PROCESSING",
+        },
+      });
+    });
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.test.ts
user: login: chris-cooksley-gds
body: Can we test that it logs COMPLETED as well?
created_at: 2025-04-17T15:06:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -386,30 +528,46 @@ describe("Async Issue Biometric Credential", () => {
         expect(mockGetSecretsSuccess).not.toHaveBeenCalled();
       });
 
-      it("Logs COMPLETED with sessionId", async () => {
+      it("Logs COMPLETED", async () => {
         expect(consoleInfoSpy).toHaveBeenCalledWithLogFields({
           messageCode: "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_COMPLETED",
-          sessionId: mockSessionId,
         });
       });
     });
 
-    describe("Given sessionState is ASYNC_BIOMETRIC_SESSION_FINISHED", () => {
+    it("Passes correct arguments to get biometric session", async () => {
+      await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
+      expect(mockGetBiometricSessionSuccess).toHaveBeenCalledWith(
+        "mockReadIdBaseUrl",
+        mockSessionId,
+        "mockViewerKey",
+      );
+    });
+
+    it("Logs COMPLETED", async () => {
+      await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
+      expect(consoleInfoSpy).toHaveBeenCalledWithLogFields({
+        messageCode: "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_COMPLETED",
+      });
+    });
+
+    describe("Given sessionState is ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_FINISHED", () => {
       beforeEach(async () => {
         await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
       });
 
-      it("Passes correct arguments to get secrets", () => {
+      it("Passes correct arguments to get secrets", async () => {
+        await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.test.ts
user: login: chris-cooksley-gds
body: Why has this been added here and in the test below?
created_at: 2025-04-17T15:06:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -386,30 +528,46 @@ describe("Async Issue Biometric Credential", () => {
         expect(mockGetSecretsSuccess).not.toHaveBeenCalled();
       });
 
-      it("Logs COMPLETED with sessionId", async () => {
+      it("Logs COMPLETED", async () => {
         expect(consoleInfoSpy).toHaveBeenCalledWithLogFields({
           messageCode: "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_COMPLETED",
-          sessionId: mockSessionId,
         });
       });
     });
 
-    describe("Given sessionState is ASYNC_BIOMETRIC_SESSION_FINISHED", () => {
+    it("Passes correct arguments to get biometric session", async () => {
+      await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.test.ts
user: login: chris-cooksley-gds
body: Shouldn't need this (or the one below), since we already call it in the beforeEach 
created_at: 2025-04-17T15:07:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,151 @@
+import { logger } from "../../common/logging/logger";
+import { LogMessage } from "../../common/logging/LogMessage";
+import {
+  HttpError,
+  ISendHttpRequest,
+  sendHttpRequest as sendHttpRequestDefault,
+  SuccessfulHttpResponse,
+} from "../../adapters/http/sendHttpRequest";
+import { Result, successResult, errorResult } from "../../utils/result";
+
+export interface BiometricSession {
+  finish: string;
+}
+
+export interface GetBiometricSessionError {
+  isRetryable: boolean;
+}
+
+export type GetBiometricSession = (
+  baseUrl: string,
+  biometricSessionId: string,
+  viewerKey: string,
+  sendHttpRequest?: ISendHttpRequest,
+) => Promise<Result<BiometricSession, GetBiometricSessionError>>;
+
+export const getBiometricSession: GetBiometricSession = async (
+  baseUrl: string,
+  biometricSessionId: string,
+  viewerKey: string,
+  sendHttpRequest: ISendHttpRequest = sendHttpRequestDefault,
+) => {
+  const httpRequest = {
+    url: `${baseUrl}/odata/v1/ODataServlet/Sessions('${biometricSessionId}')`,
+    method: "GET" as const,
+    headers: {
+      "X-Innovalor-Authorization": viewerKey,
+      Accept: "application/json;odata.metadata=minimal",
+      "Content-Type": "application/json;odata.metadata=minimal",
+      "OData-MaxVersion": "4.0",
+      "OData-Version": "4.0",
+    },
+  };
+
+  const retryConfig = {
+    retryableStatusCodes: [
+      429, 500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511,
+    ],
+    delayInMillis: 50,
+  };
+
+  logger.debug(LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_ATTEMPT, {
+    data: {
+      biometricSessionId,
+    },
+  });
+
+  const getBiometricSessionResult: Result<SuccessfulHttpResponse, HttpError> =
+    await sendHttpRequest(httpRequest, retryConfig);
+
+  if (getBiometricSessionResult.isError) {
+    const error = getBiometricSessionResult.value;
+    const statusCode = error.statusCode ? Number(error.statusCode) : undefined;
+    const retryableStatusCodes = retryConfig.retryableStatusCodes;
+
+    const isRetryable =
+      !statusCode || retryableStatusCodes.includes(statusCode);
+
+    logger.error(
+      LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE,
+      {
+        data: {
+          error,
+          isRetryable,
+          biometricSessionId,
+        },
+      },
+    );
+
+    return errorResult({
+      statusCode,
path: backend-api/src/functions/asyncIssueBiometricCredential/getBiometricSession/getBiometricSession.ts
user: login: tmulberry-gds
body: Removed
created_at: 2025-04-17T15:12:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -386,30 +528,46 @@ describe("Async Issue Biometric Credential", () => {
         expect(mockGetSecretsSuccess).not.toHaveBeenCalled();
       });
 
-      it("Logs COMPLETED with sessionId", async () => {
+      it("Logs COMPLETED", async () => {
         expect(consoleInfoSpy).toHaveBeenCalledWithLogFields({
           messageCode: "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_COMPLETED",
-          sessionId: mockSessionId,
         });
       });
     });
 
-    describe("Given sessionState is ASYNC_BIOMETRIC_SESSION_FINISHED", () => {
+    it("Passes correct arguments to get biometric session", async () => {
+      await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
+      expect(mockGetBiometricSessionSuccess).toHaveBeenCalledWith(
+        "mockReadIdBaseUrl",
+        mockSessionId,
+        "mockViewerKey",
+      );
+    });
+
+    it("Logs COMPLETED", async () => {
+      await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
+      expect(consoleInfoSpy).toHaveBeenCalledWithLogFields({
+        messageCode: "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_COMPLETED",
+      });
+    });
+
+    describe("Given sessionState is ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_FINISHED", () => {
       beforeEach(async () => {
         await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
       });
 
-      it("Passes correct arguments to get secrets", () => {
+      it("Passes correct arguments to get secrets", async () => {
+        await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.test.ts
user: login: tmulberry-gds
body: Removed them as they're not needed
created_at: 2025-04-17T15:13:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -386,30 +528,46 @@ describe("Async Issue Biometric Credential", () => {
         expect(mockGetSecretsSuccess).not.toHaveBeenCalled();
       });
 
-      it("Logs COMPLETED with sessionId", async () => {
+      it("Logs COMPLETED", async () => {
         expect(consoleInfoSpy).toHaveBeenCalledWithLogFields({
           messageCode: "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_COMPLETED",
-          sessionId: mockSessionId,
         });
       });
     });
 
-    describe("Given sessionState is ASYNC_BIOMETRIC_SESSION_FINISHED", () => {
+    it("Passes correct arguments to get biometric session", async () => {
+      await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.test.ts
user: login: tmulberry-gds
body: Removed it
created_at: 2025-04-17T15:15:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -370,6 +414,104 @@ describe("Async Issue Biometric Credential", () => {
     });
   });
 
+  describe("When biometric session is not ready", () => {
+    beforeEach(async () => {
+      dependencies.getBiometricSession = mockGetBiometricSessionNotReady;
+      try {
+        await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
+      } catch (error: unknown) {
+        lambdaError = error;
+      }
+    });
+
+    it("Logs the appropriate message", () => {
+      expect(consoleInfoSpy).toHaveBeenCalledWithLogFields({
+        messageCode: "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_NOT_READY",
+        data: {
+          finish: "PROCESSING",
+        },
+      });
+    });
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.test.ts
user: login: tmulberry-gds
body: Added the logs completed test
created_at: 2025-04-17T15:16:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -386,30 +534,37 @@ describe("Async Issue Biometric Credential", () => {
         expect(mockGetSecretsSuccess).not.toHaveBeenCalled();
       });
 
-      it("Logs COMPLETED with sessionId", async () => {
+      it("Logs COMPLETED", async () => {
         expect(consoleInfoSpy).toHaveBeenCalledWithLogFields({
           messageCode: "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_COMPLETED",
-          sessionId: mockSessionId,
         });
       });
     });
 
-    describe("Given sessionState is ASYNC_BIOMETRIC_SESSION_FINISHED", () => {
-      beforeEach(async () => {
-        await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
+    it("Passes correct arguments to get biometric session", async () => {
+      await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
+      expect(mockGetBiometricSessionSuccess).toHaveBeenCalledWith(
+        "mockReadIdBaseUrl",
+        mockSessionId,
+        "mockViewerKey",
+      );
+    });
+
+    it("Logs COMPLETED", async () => {
+      await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
+      expect(consoleInfoSpy).toHaveBeenCalledWithLogFields({
+        messageCode: "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_COMPLETED",
       });
+    });
 
-      it("Passes correct arguments to get secrets", () => {
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.test.ts
user: login: chris-cooksley-gds
body: Why is this test case being deleted?
created_at: 2025-04-17T15:20:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -370,6 +414,104 @@ describe("Async Issue Biometric Credential", () => {
     });
   });
 
+  describe("When biometric session is not ready", () => {
+    beforeEach(async () => {
+      dependencies.getBiometricSession = mockGetBiometricSessionNotReady;
+      try {
+        await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
+      } catch (error: unknown) {
+        lambdaError = error;
+      }
+    });
+
+    it("Logs the appropriate message", () => {
+      expect(consoleInfoSpy).toHaveBeenCalledWithLogFields({
+        messageCode: "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_NOT_READY",
+        data: {
+          finish: "PROCESSING",
+        },
+      });
+    });
+
+    it("Throws RetainMessageOnQueue with appropriate message", () => {
+      expect(lambdaError).toBeInstanceOf(RetainMessageOnQueue);
+      expect((lambdaError as RetainMessageOnQueue).message).toMatch(
+        /Biometric session not ready: PROCESSING/,
+      );
+    });
+  });
+
+  describe("When biometric session retrieval fails", () => {
+    describe("With retryable error", () => {
+      beforeEach(async () => {
+        dependencies.getBiometricSession =
+          mockGetBiometricSessionRetryableFailure;
+        try {
+          await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
+        } catch (error: unknown) {
+          lambdaError = error;
+        }
+      });
+
+      it("Throws RetainMessageOnQueue with appropriate message", () => {
+        expect(lambdaError).toBeInstanceOf(RetainMessageOnQueue);
+        expect((lambdaError as RetainMessageOnQueue).message).toMatch(
+          /Retryable error/,
+        );
+      });
+    });
+
+    describe("With non-retryable error", () => {
+      beforeEach(async () => {
+        dependencies.getBiometricSession =
+          mockGetBiometricSessionNonRetryableFailure;
+        await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
+      });
+
+      it("Sends error to IPV Core", () => {
+        expect(mockSuccessfulSendMessageToSqs).toHaveBeenCalledWith(
+          "mockIpvcoreOutboundSqs",
+          {
+            sub: "mockSubjectIdentifier",
+            state: "mockClientState",
+            error: "server_error",
+            error_description: "Internal server error",
+          },
+        );
+      });
+
+      it("Sends event to TxMA", () => {
+        expect(mockWriteGenericEventSuccessResult).toHaveBeenCalledWith(
+          expect.objectContaining({
+            eventName: "DCMAW_ASYNC_CRI_5XXERROR",
+            componentId: "mockIssuer",
+          }),
+        );
+      });
+
+      describe("When sending error to IPV Core fails", () => {
+        beforeEach(async () => {
+          dependencies.sendMessageToSqs = jest
+            .fn()
+            .mockResolvedValue(emptyFailure());
+          dependencies.getBiometricSession =
+            mockGetBiometricSessionNonRetryableFailure;
+          await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
+        });
+
+        it("Logs IPV Core message error", () => {
+          expect(consoleErrorSpy).toHaveBeenCalledWithLogFields({
+            messageCode:
+              "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_IPV_CORE_MESSAGE_ERROR",
+          });
+        });
+
+        it("Still sends event to TxMA", () => {
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.test.ts
user: login: tmulberry-gds
body: Added more tests for txma under this
created_at: 2025-04-17T15:20:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,334 @@
+import { errorResult, Result, successResult } from "../../utils/result";
+import {
+  getBiometricSession,
+  BiometricSession,
+  GetBiometricSessionError,
+} from "./getBiometricSession";
+import { expect } from "@jest/globals";
+import "../../../../tests/testUtils/matchers";
+import { ISendHttpRequest } from "../../adapters/http/sendHttpRequest";
+
+describe("getBiometricSession", () => {
+  let result: Result<BiometricSession, GetBiometricSessionError>;
+  let consoleDebugSpy: jest.SpyInstance;
+  let consoleErrorSpy: jest.SpyInstance;
+  let mockSendHttpRequest: ISendHttpRequest;
+  const mockSessionId = "mockSessionId";
+
+  const expectedHttpRequest = {
+    url: "https://mockUrl.com/odata/v1/ODataServlet/Sessions('mockSessionId')",
+    method: "GET",
+    headers: {
+      "X-Innovalor-Authorization": "mockSubmitterKey",
+      Accept: "application/json;odata.metadata=minimal",
+      "Content-Type": "application/json;odata.metadata=minimal",
+      "OData-MaxVersion": "4.0",
+      "OData-Version": "4.0",
+    },
+  };
+
+  const expectedRetryConfig = {
+    retryableStatusCodes: [
+      429, 500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511,
+    ],
+    delayInMillis: 50,
+  };
+
+  beforeEach(() => {
+    consoleDebugSpy = jest.spyOn(console, "debug");
+    consoleErrorSpy = jest.spyOn(console, "error");
+
+    jest.resetModules();
+  });
+
+  describe("On every call", () => {
+    beforeEach(async () => {
+      mockSendHttpRequest = jest.fn().mockResolvedValue(
+        successResult({
+          statusCode: 200,
+          body: JSON.stringify({
+            id: mockSessionId,
+            finish: "DONE",
+          }),
+          headers: {
+            mockHeaderKey: "mockHeaderValue",
+          },
+        }),
+      );
+
+      result = await getBiometricSession(
+        "https://mockUrl.com",
+        mockSessionId,
+        "mockSubmitterKey",
+        mockSendHttpRequest,
+      );
+    });
+
+    it("Logs network call attempt at debug level", () => {
+      expect(consoleDebugSpy).toHaveBeenCalledWithLogFields({
+        messageCode:
+          "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_ATTEMPT",
+      });
+    });
+  });
+
+  describe("Given an error is returned when requesting session", () => {
+    describe("Given a retryable error (5XX)", () => {
+      beforeEach(async () => {
+        mockSendHttpRequest = jest.fn().mockResolvedValue(
+          errorResult({
+            statusCode: 503,
+            message: "Service Unavailable",
+          }),
+        );
+        result = await getBiometricSession(
+          "https://mockUrl.com",
+          mockSessionId,
+          "mockSubmitterKey",
+          mockSendHttpRequest,
+        );
+      });
+
+      it("Logs error", () => {
+        expect(consoleErrorSpy).toHaveBeenCalledWithLogFields({
+          messageCode:
+            "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE",
+        });
+      });
+
+      it("Returns an error result with retryable flag", () => {
+        expect(result.isError).toBe(true);
+        expect(result.value).toEqual({
+          statusCode: 503,
+          message: "Failed to retrieve biometric session",
+          isRetryable: true,
+        });
+        expect(mockSendHttpRequest).toBeCalledWith(
+          expectedHttpRequest,
+          expectedRetryConfig,
+        );
+      });
+    });
+
+    describe("Given a non-retryable error (4XX)", () => {
+      beforeEach(async () => {
+        mockSendHttpRequest = jest.fn().mockResolvedValue(
+          errorResult({
+            statusCode: 404,
+            message: "Not Found",
+          }),
+        );
+        result = await getBiometricSession(
+          "https://mockUrl.com",
+          mockSessionId,
+          "mockSubmitterKey",
+          mockSendHttpRequest,
+        );
+      });
+
+      it("Logs error", () => {
+        expect(consoleErrorSpy).toHaveBeenCalledWithLogFields({
+          messageCode:
+            "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE",
+        });
+      });
+
+      it("Returns an error result with non-retryable flag", () => {
+        expect(result.isError).toBe(true);
+        expect(result.value).toEqual({
+          statusCode: 404,
+          message: "Failed to retrieve biometric session",
+          isRetryable: false,
+        });
+        expect(mockSendHttpRequest).toBeCalledWith(
+          expectedHttpRequest,
+          expectedRetryConfig,
+        );
+      });
+    });
+
+    describe("Given a network error (no status code)", () => {
+      beforeEach(async () => {
+        mockSendHttpRequest = jest.fn().mockResolvedValue(
+          errorResult({
+            message: "Network Error",
+          }),
+        );
+        result = await getBiometricSession(
+          "https://mockUrl.com",
+          mockSessionId,
+          "mockSubmitterKey",
+          mockSendHttpRequest,
+        );
+      });
+
+      it("Treats it as retryable", () => {
+        expect(result.isError).toBe(true);
+        expect(result.value).toEqual({
+          statusCode: undefined,
+          message: "Failed to retrieve biometric session",
+          isRetryable: true,
+        });
+      });
+    });
+  });
+
+  describe("Given the response is invalid", () => {
+    describe("Given response body is undefined", () => {
+      beforeEach(async () => {
+        mockSendHttpRequest = jest.fn().mockResolvedValue(
+          successResult({
+            statusCode: 200,
+            body: undefined,
+            headers: {
+              mockHeaderKey: "mockHeaderValue",
+            },
+          }),
+        );
+
+        result = await getBiometricSession(
+          "https://mockUrl.com",
+          mockSessionId,
+          "mockSubmitterKey",
+          mockSendHttpRequest,
+        );
+      });
+
+      it("Logs error", () => {
+        expect(consoleErrorSpy).toHaveBeenCalledWithLogFields({
+          messageCode:
+            "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE",
+        });
+      });
+
+      it("Returns an error result with retryable flag", () => {
+        expect(result.isError).toBe(true);
+        expect(result.value).toEqual({
+          message: "Empty response body from ReadID",
+          isRetryable: false,
+        });
+        expect(mockSendHttpRequest).toBeCalledWith(
+          expectedHttpRequest,
+          expectedRetryConfig,
+        );
+      });
+    });
+
+    describe("Given response body cannot be parsed", () => {
+      beforeEach(async () => {
+        mockSendHttpRequest = jest.fn().mockResolvedValue(
+          successResult({
+            statusCode: 200,
+            body: "Invalid JSON",
+            headers: {
+              mockHeaderKey: "mockHeaderValue",
+            },
+          }),
+        );
+
+        result = await getBiometricSession(
+          "https://mockUrl.com",
+          mockSessionId,
+          "mockSubmitterKey",
+          mockSendHttpRequest,
+        );
+      });
+
+      it("Logs error", () => {
+        expect(consoleErrorSpy).toHaveBeenCalledWithLogFields({
+          messageCode:
+            "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE",
+        });
+      });
+
+      it("Returns an error result with retryable flag", () => {
+        expect(result.isError).toBe(true);
+        expect(result.value).toEqual({
+          message: "Failed to parse response JSON",
+          isRetryable: false,
+        });
+        expect(mockSendHttpRequest).toBeCalledWith(
+          expectedHttpRequest,
+          expectedRetryConfig,
+        );
+      });
+    });
+
+    describe("Given response has invalid structure", () => {
+      beforeEach(async () => {
+        mockSendHttpRequest = jest.fn().mockResolvedValue(
+          successResult({
+            statusCode: 200,
+            body: JSON.stringify({ id: mockSessionId, status: "COMPLETE" }),
+            headers: {
+              mockHeaderKey: "mockHeaderValue",
+            },
+          }),
+        );
+
+        result = await getBiometricSession(
+          "https://mockUrl.com",
+          mockSessionId,
+          "mockSubmitterKey",
+          mockSendHttpRequest,
+        );
+      });
+
+      it("Logs error", () => {
+        expect(consoleErrorSpy).toHaveBeenCalledWithLogFields({
+          messageCode:
+            "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE",
+        });
+      });
+
+      it("Returns an error result with non-retryable flag", () => {
+        expect(result.isError).toBe(true);
+        expect(result.value).toEqual({
+          message: "Invalid response structure from ReadID",
+          isRetryable: false,
+        });
+      });
+    });
+  });
+
+  describe("Given valid request is made", () => {
+    const mockSession = {
+      id: mockSessionId,
+      finish: "DONE",
+    };
+
+    beforeEach(async () => {
+      mockSendHttpRequest = jest.fn().mockResolvedValue(
+        successResult({
+          statusCode: 200,
+          body: JSON.stringify(mockSession),
+          headers: {
+            mockHeaderKey: "mockHeaderValue",
+          },
+        }),
+      );
+
+      result = await getBiometricSession(
+        "https://mockUrl.com",
+        mockSessionId,
+        "mockSubmitterKey",
+        mockSendHttpRequest,
+      );
+    });
+
+    it("Logs success at debug level", () => {
+      expect(consoleDebugSpy).toHaveBeenCalledWithLogFields({
+        messageCode:
+          "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_SUCCESS",
+      });
+    });
+
+    it("Returns successResult containing biometric session", () => {
+      expect(result).toEqual(successResult(mockSession));
+      expect(mockSendHttpRequest).toBeCalledWith(
path: backend-api/src/functions/asyncIssueBiometricCredential/getBiometricSession/getBiometricSession.test.ts
user: login: tmulberry-gds
body: changed to an 'it' block
created_at: 2025-04-17T20:20:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -75,6 +84,74 @@ export async function lambdaHandlerConstructor(
     throw new RetainMessageOnQueue("Failed to retrieve biometric viewer key");
   }
 
+  const viewerKey = viewerKeyResult.value;
+
+  const biometricSessionResult = await dependencies.getBiometricSession(
+    config.READID_BASE_URL,
+    sessionAttributes.sessionId,
+    viewerKey,
+  );
+
+  if (biometricSessionResult.isError) {
+    const eventService = dependencies.getEventService(config.TXMA_SQS);
+    const error: GetBiometricSessionError = biometricSessionResult.value;
+
+    // Check if the error was retryable based on error info
+    if (error.isRetryable) {
+      throw new RetainMessageOnQueue(
+        `Retryable error retrieving biometric session`,
+      );
+    }
+
+    const handleSendErrorMessageToOutboundQueueResponse =
+      await handleSendErrorMessageToOutboundQueue(
+        dependencies,
+        sessionAttributes,
+        config,
+        { error: "server_error", error_description: "Internal server error" },
+      );
+    if (handleSendErrorMessageToOutboundQueueResponse.isError) {
+      logger.error(
+        LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_IPV_CORE_MESSAGE_ERROR,
+      );
+    }
+
+    const writeEventResult = await eventService.writeGenericEvent({
+      eventName: "DCMAW_ASYNC_CRI_5XXERROR",
+      sub: sessionAttributes?.subjectIdentifier,
+      sessionId,
+      govukSigninJourneyId: sessionAttributes?.govukSigninJourneyId,
+      getNowInMilliseconds: Date.now,
+      componentId: config.ISSUER,
+      ipAddress: undefined,
+      txmaAuditEncoded: undefined,
+      redirect_uri: sessionAttributes?.redirectUri,
+      suspected_fraud_signal: undefined,
+    });
+
+    if (writeEventResult.isError) {
+      logger.error(LogMessage.ERROR_WRITING_AUDIT_EVENT);
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.ts
user: login: chris-cooksley-gds
body: Can we add `data` with the audit event name? (cf. what we log when we fail to write an event in biometricToken or abortSession)
created_at: 2025-04-22T08:59:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -370,6 +414,185 @@ describe("Async Issue Biometric Credential", () => {
     });
   });
 
+  describe("When biometric session is not ready", () => {
+    beforeEach(async () => {
+      dependencies.getBiometricSession = mockGetBiometricSessionNotReady;
+      try {
+        await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
+      } catch (error: unknown) {
+        lambdaError = error;
+      }
+    });
+
+    it("Logs the appropriate message", () => {
+      expect(consoleInfoSpy).toHaveBeenCalledWithLogFields({
+        messageCode: "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_NOT_READY",
+        data: {
+          finish: "PROCESSING",
+        },
+      });
+    });
+
+    it("Throws RetainMessageOnQueue with appropriate message", () => {
+      expect(lambdaError).toBeInstanceOf(RetainMessageOnQueue);
+      expect((lambdaError as RetainMessageOnQueue).message).toMatch(
+        /Biometric session not ready: PROCESSING/,
+      );
+    });
+
+    it("Logs COMPLETED", () => {
+      expect(consoleInfoSpy).toHaveBeenCalledWithLogFields({
+        messageCode: "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_COMPLETED",
+      });
+    });
+  });
+
+  describe("When biometric session retrieval fails", () => {
+    describe("With retryable error", () => {
+      beforeEach(async () => {
+        dependencies.getBiometricSession =
+          mockGetBiometricSessionRetryableFailure;
+        try {
+          await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
+        } catch (error: unknown) {
+          lambdaError = error;
+        }
+      });
+
+      it("Throws RetainMessageOnQueue with appropriate message", () => {
+        expect(lambdaError).toBeInstanceOf(RetainMessageOnQueue);
+        expect((lambdaError as RetainMessageOnQueue).message).toMatch(
+          /Retryable error/,
+        );
+      });
+    });
+
+    describe("With non-retryable error", () => {
+      beforeEach(async () => {
+        dependencies.getBiometricSession =
+          mockGetBiometricSessionNonRetryableFailure;
+        await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
+      });
+
+      it("Sends error to IPV Core", () => {
+        expect(mockSuccessfulSendMessageToSqs).toHaveBeenCalledWith(
+          "mockIpvcoreOutboundSqs",
+          {
+            sub: "mockSubjectIdentifier",
+            state: "mockClientState",
+            error: "server_error",
+            error_description: "Internal server error",
+          },
+        );
+      });
+
+      it("Sends event to TxMA", () => {
+        expect(mockWriteGenericEventSuccessResult).toHaveBeenCalledWith(
+          expect.objectContaining({
+            eventName: "DCMAW_ASYNC_CRI_5XXERROR",
+            componentId: "mockIssuer",
+          }),
+        );
+      });
+
+      describe("When sending error to IPV Core fails", () => {
+        beforeEach(async () => {
+          dependencies.sendMessageToSqs = jest
+            .fn()
+            .mockResolvedValue(emptyFailure());
+          dependencies.getBiometricSession =
+            mockGetBiometricSessionNonRetryableFailure;
+          await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
+        });
+
+        it("Logs IPV Core message error", () => {
+          expect(consoleErrorSpy).toHaveBeenCalledWithLogFields({
+            messageCode:
+              "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_IPV_CORE_MESSAGE_ERROR",
+          });
+        });
+
+        it("Still sends event to TxMA", () => {
+          expect(mockWriteGenericEventSuccessResult).toHaveBeenCalled();
+        });
+        describe("Given writing TxMA event fails", () => {
+          beforeEach(async () => {
+            dependencies.getSessionRegistry = () => ({
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.test.ts
user: login: chris-cooksley-gds
body: Why are we overriding the session registry to make the get operation fail, for this scenario?
created_at: 2025-04-22T09:02:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -370,6 +414,185 @@ describe("Async Issue Biometric Credential", () => {
     });
   });
 
+  describe("When biometric session is not ready", () => {
+    beforeEach(async () => {
+      dependencies.getBiometricSession = mockGetBiometricSessionNotReady;
+      try {
+        await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
+      } catch (error: unknown) {
+        lambdaError = error;
+      }
+    });
+
+    it("Logs the appropriate message", () => {
+      expect(consoleInfoSpy).toHaveBeenCalledWithLogFields({
+        messageCode: "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_NOT_READY",
+        data: {
+          finish: "PROCESSING",
+        },
+      });
+    });
+
+    it("Throws RetainMessageOnQueue with appropriate message", () => {
+      expect(lambdaError).toBeInstanceOf(RetainMessageOnQueue);
+      expect((lambdaError as RetainMessageOnQueue).message).toMatch(
+        /Biometric session not ready: PROCESSING/,
+      );
+    });
+
+    it("Logs COMPLETED", () => {
+      expect(consoleInfoSpy).toHaveBeenCalledWithLogFields({
+        messageCode: "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_COMPLETED",
+      });
+    });
+  });
+
+  describe("When biometric session retrieval fails", () => {
+    describe("With retryable error", () => {
+      beforeEach(async () => {
+        dependencies.getBiometricSession =
+          mockGetBiometricSessionRetryableFailure;
+        try {
+          await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
+        } catch (error: unknown) {
+          lambdaError = error;
+        }
+      });
+
+      it("Throws RetainMessageOnQueue with appropriate message", () => {
+        expect(lambdaError).toBeInstanceOf(RetainMessageOnQueue);
+        expect((lambdaError as RetainMessageOnQueue).message).toMatch(
+          /Retryable error/,
+        );
+      });
+    });
+
+    describe("With non-retryable error", () => {
+      beforeEach(async () => {
+        dependencies.getBiometricSession =
+          mockGetBiometricSessionNonRetryableFailure;
+        await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
+      });
+
+      it("Sends error to IPV Core", () => {
+        expect(mockSuccessfulSendMessageToSqs).toHaveBeenCalledWith(
+          "mockIpvcoreOutboundSqs",
+          {
+            sub: "mockSubjectIdentifier",
+            state: "mockClientState",
+            error: "server_error",
+            error_description: "Internal server error",
+          },
+        );
+      });
+
+      it("Sends event to TxMA", () => {
+        expect(mockWriteGenericEventSuccessResult).toHaveBeenCalledWith(
+          expect.objectContaining({
+            eventName: "DCMAW_ASYNC_CRI_5XXERROR",
+            componentId: "mockIssuer",
+          }),
+        );
+      });
+
+      describe("When sending error to IPV Core fails", () => {
+        beforeEach(async () => {
+          dependencies.sendMessageToSqs = jest
+            .fn()
+            .mockResolvedValue(emptyFailure());
+          dependencies.getBiometricSession =
+            mockGetBiometricSessionNonRetryableFailure;
+          await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
+        });
+
+        it("Logs IPV Core message error", () => {
+          expect(consoleErrorSpy).toHaveBeenCalledWithLogFields({
+            messageCode:
+              "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_IPV_CORE_MESSAGE_ERROR",
+          });
+        });
+
+        it("Still sends event to TxMA", () => {
+          expect(mockWriteGenericEventSuccessResult).toHaveBeenCalled();
+        });
+        describe("Given writing TxMA event fails", () => {
+          beforeEach(async () => {
+            dependencies.getSessionRegistry = () => ({
+              ...mockInertSessionRegistry,
+              getSession: jest.fn().mockResolvedValue(
+                errorResult({
+                  errorType: GetSessionError.CLIENT_ERROR,
+                }),
+              ),
+            });
+            dependencies.getEventService = () => ({
+              ...mockInertEventService,
+              writeGenericEvent: jest.fn().mockResolvedValue(
+                errorResult({
+                  errorMessage: "mockError",
+                }),
+              ),
+            });
+
+            await lambdaHandlerConstructor(
+              dependencies,
+              validSqsEvent,
+              context,
+            );
+          });
+
+          it("Logs DCMAW_ASYNC_CRI_5XXERROR audit event error", () => {
+            expect(consoleErrorSpy).toHaveBeenCalledWithLogFields({
+              messageCode: "MOBILE_ASYNC_ERROR_WRITING_AUDIT_EVENT",
+              data: { auditEventName: "DCMAW_ASYNC_CRI_5XXERROR" },
+            });
+          });
+
+          it("Does not log COMPLETED", () => {
+            expect(consoleInfoSpy).not.toHaveBeenCalledWithLogFields({
+              messageCode: "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_COMPLETED",
+            });
+          });
+        });
+
+        describe("Given writing TxMA event succeeds", () => {
+          beforeEach(async () => {
+            dependencies.getSessionRegistry = () => ({
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.test.ts
user: login: chris-cooksley-gds
body: Why are we overriding the session registry to make the get operation fail, for this scenario?
created_at: 2025-04-22T09:03:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -75,6 +84,74 @@ export async function lambdaHandlerConstructor(
     throw new RetainMessageOnQueue("Failed to retrieve biometric viewer key");
   }
 
+  const viewerKey = viewerKeyResult.value;
+
+  const biometricSessionResult = await dependencies.getBiometricSession(
+    config.READID_BASE_URL,
+    sessionAttributes.sessionId,
+    viewerKey,
+  );
+
+  if (biometricSessionResult.isError) {
+    const eventService = dependencies.getEventService(config.TXMA_SQS);
+    const error: GetBiometricSessionError = biometricSessionResult.value;
+
+    // Check if the error was retryable based on error info
+    if (error.isRetryable) {
+      throw new RetainMessageOnQueue(
+        `Retryable error retrieving biometric session`,
+      );
+    }
+
+    const handleSendErrorMessageToOutboundQueueResponse =
+      await handleSendErrorMessageToOutboundQueue(
+        dependencies,
+        sessionAttributes,
+        config,
+        { error: "server_error", error_description: "Internal server error" },
+      );
+    if (handleSendErrorMessageToOutboundQueueResponse.isError) {
+      logger.error(
+        LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_IPV_CORE_MESSAGE_ERROR,
+      );
+    }
+
+    const writeEventResult = await eventService.writeGenericEvent({
+      eventName: "DCMAW_ASYNC_CRI_5XXERROR",
+      sub: sessionAttributes?.subjectIdentifier,
+      sessionId,
+      govukSigninJourneyId: sessionAttributes?.govukSigninJourneyId,
+      getNowInMilliseconds: Date.now,
+      componentId: config.ISSUER,
+      ipAddress: undefined,
+      txmaAuditEncoded: undefined,
+      redirect_uri: sessionAttributes?.redirectUri,
+      suspected_fraud_signal: undefined,
+    });
+
+    if (writeEventResult.isError) {
+      logger.error(LogMessage.ERROR_WRITING_AUDIT_EVENT);
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.ts
user: login: tmulberry-gds
body: Added log data to log
created_at: 2025-04-22T09:03:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -386,30 +609,43 @@ describe("Async Issue Biometric Credential", () => {
         expect(mockGetSecretsSuccess).not.toHaveBeenCalled();
       });
 
-      it("Logs COMPLETED with sessionId", async () => {
+      it("Logs COMPLETED", async () => {
         expect(consoleInfoSpy).toHaveBeenCalledWithLogFields({
           messageCode: "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_COMPLETED",
-          sessionId: mockSessionId,
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.test.ts
user: login: chris-cooksley-gds
body: Why has this been removed?
created_at: 2025-04-22T09:03:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -386,30 +609,43 @@ describe("Async Issue Biometric Credential", () => {
         expect(mockGetSecretsSuccess).not.toHaveBeenCalled();
       });
 
-      it("Logs COMPLETED with sessionId", async () => {
+      it("Logs COMPLETED", async () => {
         expect(consoleInfoSpy).toHaveBeenCalledWithLogFields({
           messageCode: "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_COMPLETED",
-          sessionId: mockSessionId,
         });
       });
     });
 
-    describe("Given sessionState is ASYNC_BIOMETRIC_SESSION_FINISHED", () => {
+    it("Passes correct arguments to get biometric session", async () => {
+      await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
+      expect(mockGetBiometricSessionSuccess).toHaveBeenCalledWith(
+        "mockReadIdBaseUrl",
+        mockSessionId,
+        "mockViewerKey",
+      );
+    });
+
+    it("Logs COMPLETED", async () => {
+      await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
+      expect(consoleInfoSpy).toHaveBeenCalledWithLogFields({
+        messageCode: "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_COMPLETED",
+      });
+    });
+
+    describe("Given sessionState is ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_FINISHED", () => {
       beforeEach(async () => {
         await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
       });
-
-      it("Passes correct arguments to get secrets", () => {
+      it("Passes correct arguments to get secrets", async () => {
         expect(mockGetSecretsSuccess).toHaveBeenCalledWith({
           secretNames: ["mockBiometricViewerAccessKey"],
           cacheDurationInSeconds: 900,
         });
       });
 
-      it("Logs COMPLETED with sessionId", async () => {
+      it("Logs COMPLETED", async () => {
         expect(consoleInfoSpy).toHaveBeenCalledWithLogFields({
           messageCode: "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_COMPLETED",
-          sessionId: mockSessionId,
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.test.ts
user: login: chris-cooksley-gds
body: Why has this been removed?
created_at: 2025-04-22T09:05:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -386,30 +609,43 @@ describe("Async Issue Biometric Credential", () => {
         expect(mockGetSecretsSuccess).not.toHaveBeenCalled();
       });
 
-      it("Logs COMPLETED with sessionId", async () => {
+      it("Logs COMPLETED", async () => {
         expect(consoleInfoSpy).toHaveBeenCalledWithLogFields({
           messageCode: "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_COMPLETED",
-          sessionId: mockSessionId,
         });
       });
     });
 
-    describe("Given sessionState is ASYNC_BIOMETRIC_SESSION_FINISHED", () => {
+    it("Passes correct arguments to get biometric session", async () => {
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.test.ts
user: login: chris-cooksley-gds
body: Should these two assertions be nested within `Given sessionState is ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_FINISHED`? We only get a biometric session in that scenario.
created_at: 2025-04-22T09:07:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -75,6 +84,74 @@ export async function lambdaHandlerConstructor(
     throw new RetainMessageOnQueue("Failed to retrieve biometric viewer key");
   }
 
+  const viewerKey = viewerKeyResult.value;
+
+  const biometricSessionResult = await dependencies.getBiometricSession(
+    config.READID_BASE_URL,
+    sessionAttributes.sessionId,
+    viewerKey,
+  );
+
+  if (biometricSessionResult.isError) {
+    const eventService = dependencies.getEventService(config.TXMA_SQS);
+    const error: GetBiometricSessionError = biometricSessionResult.value;
+
+    // Check if the error was retryable based on error info
+    if (error.isRetryable) {
+      throw new RetainMessageOnQueue(
+        `Retryable error retrieving biometric session`,
+      );
+    }
+
+    const handleSendErrorMessageToOutboundQueueResponse =
+      await handleSendErrorMessageToOutboundQueue(
+        dependencies,
+        sessionAttributes,
+        config,
+        { error: "server_error", error_description: "Internal server error" },
+      );
+    if (handleSendErrorMessageToOutboundQueueResponse.isError) {
+      logger.error(
+        LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_IPV_CORE_MESSAGE_ERROR,
+      );
+    }
+
+    const writeEventResult = await eventService.writeGenericEvent({
+      eventName: "DCMAW_ASYNC_CRI_5XXERROR",
+      sub: sessionAttributes?.subjectIdentifier,
+      sessionId,
+      govukSigninJourneyId: sessionAttributes?.govukSigninJourneyId,
+      getNowInMilliseconds: Date.now,
+      componentId: config.ISSUER,
+      ipAddress: undefined,
+      txmaAuditEncoded: undefined,
+      redirect_uri: sessionAttributes?.redirectUri,
+      suspected_fraud_signal: undefined,
+    });
+
+    if (writeEventResult.isError) {
+      logger.error(LogMessage.ERROR_WRITING_AUDIT_EVENT);
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.ts
user: login: chris-cooksley-gds
body: I can see you've added the `writeEventResult` as log data. To clarify what I meant by this - we want it to match what we do elsewhere, e.g. [here](https://github.com/govuk-one-login/mobile-id-check-async/blob/main/backend-api/src/functions/asyncBiometricToken/asyncBiometricTokenHandler.ts#L282).
created_at: 2025-04-22T09:10:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -75,6 +84,76 @@ export async function lambdaHandlerConstructor(
     throw new RetainMessageOnQueue("Failed to retrieve biometric viewer key");
   }
 
+  const viewerKey = viewerKeyResult.value;
+
+  const biometricSessionResult = await dependencies.getBiometricSession(
+    config.READID_BASE_URL,
+    sessionAttributes.sessionId,
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.ts
user: login: chris-cooksley-gds
body: This still needs changing. Please see unaddressed comment [here](https://github.com/govuk-one-login/mobile-id-check-async/pull/586#discussion_r2048536820).
created_at: 2025-04-22T09:12:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -386,30 +609,43 @@ describe("Async Issue Biometric Credential", () => {
         expect(mockGetSecretsSuccess).not.toHaveBeenCalled();
       });
 
-      it("Logs COMPLETED with sessionId", async () => {
+      it("Logs COMPLETED", async () => {
         expect(consoleInfoSpy).toHaveBeenCalledWithLogFields({
           messageCode: "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_COMPLETED",
-          sessionId: mockSessionId,
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.test.ts
user: login: tmulberry-gds
body: Added back in
created_at: 2025-04-22T09:57:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -386,30 +609,43 @@ describe("Async Issue Biometric Credential", () => {
         expect(mockGetSecretsSuccess).not.toHaveBeenCalled();
       });
 
-      it("Logs COMPLETED with sessionId", async () => {
+      it("Logs COMPLETED", async () => {
         expect(consoleInfoSpy).toHaveBeenCalledWithLogFields({
           messageCode: "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_COMPLETED",
-          sessionId: mockSessionId,
         });
       });
     });
 
-    describe("Given sessionState is ASYNC_BIOMETRIC_SESSION_FINISHED", () => {
+    it("Passes correct arguments to get biometric session", async () => {
+      await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
+      expect(mockGetBiometricSessionSuccess).toHaveBeenCalledWith(
+        "mockReadIdBaseUrl",
+        mockSessionId,
+        "mockViewerKey",
+      );
+    });
+
+    it("Logs COMPLETED", async () => {
+      await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
+      expect(consoleInfoSpy).toHaveBeenCalledWithLogFields({
+        messageCode: "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_COMPLETED",
+      });
+    });
+
+    describe("Given sessionState is ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_FINISHED", () => {
       beforeEach(async () => {
         await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
       });
-
-      it("Passes correct arguments to get secrets", () => {
+      it("Passes correct arguments to get secrets", async () => {
         expect(mockGetSecretsSuccess).toHaveBeenCalledWith({
           secretNames: ["mockBiometricViewerAccessKey"],
           cacheDurationInSeconds: 900,
         });
       });
 
-      it("Logs COMPLETED with sessionId", async () => {
+      it("Logs COMPLETED", async () => {
         expect(consoleInfoSpy).toHaveBeenCalledWithLogFields({
           messageCode: "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_COMPLETED",
-          sessionId: mockSessionId,
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.test.ts
user: login: tmulberry-gds
body: added back in
created_at: 2025-04-22T09:57:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -386,30 +609,43 @@ describe("Async Issue Biometric Credential", () => {
         expect(mockGetSecretsSuccess).not.toHaveBeenCalled();
       });
 
-      it("Logs COMPLETED with sessionId", async () => {
+      it("Logs COMPLETED", async () => {
         expect(consoleInfoSpy).toHaveBeenCalledWithLogFields({
           messageCode: "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_COMPLETED",
-          sessionId: mockSessionId,
         });
       });
     });
 
-    describe("Given sessionState is ASYNC_BIOMETRIC_SESSION_FINISHED", () => {
+    it("Passes correct arguments to get biometric session", async () => {
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.test.ts
user: login: tmulberry-gds
body: Moved it out of nest
created_at: 2025-04-22T09:59:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -74,7 +79,91 @@ export async function lambdaHandlerConstructor(
   if (viewerKeyResult.isError) {
     throw new RetainMessageOnQueue("Failed to retrieve biometric viewer key");
   }
+  const submitterKey = viewerKeyResult.value;
+
+  const biometricSessionResult = await dependencies.getBiometricSession(
+    config.READID_BASE_URL,
+    sessionId,
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.ts
user: login: tmulberry-gds
body: Ah, got it. Added it in
created_at: 2025-04-22T10:38:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -75,6 +84,76 @@ export async function lambdaHandlerConstructor(
     throw new RetainMessageOnQueue("Failed to retrieve biometric viewer key");
   }
 
+  const viewerKey = viewerKeyResult.value;
+
+  const biometricSessionResult = await dependencies.getBiometricSession(
+    config.READID_BASE_URL,
+    sessionAttributes.sessionId,
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.ts
user: login: tmulberry-gds
body: Added
created_at: 2025-04-22T10:39:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -370,6 +414,185 @@ describe("Async Issue Biometric Credential", () => {
     });
   });
 
+  describe("When biometric session is not ready", () => {
+    beforeEach(async () => {
+      dependencies.getBiometricSession = mockGetBiometricSessionNotReady;
+      try {
+        await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
+      } catch (error: unknown) {
+        lambdaError = error;
+      }
+    });
+
+    it("Logs the appropriate message", () => {
+      expect(consoleInfoSpy).toHaveBeenCalledWithLogFields({
+        messageCode: "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_NOT_READY",
+        data: {
+          finish: "PROCESSING",
+        },
+      });
+    });
+
+    it("Throws RetainMessageOnQueue with appropriate message", () => {
+      expect(lambdaError).toBeInstanceOf(RetainMessageOnQueue);
+      expect((lambdaError as RetainMessageOnQueue).message).toMatch(
+        /Biometric session not ready: PROCESSING/,
+      );
+    });
+
+    it("Logs COMPLETED", () => {
+      expect(consoleInfoSpy).toHaveBeenCalledWithLogFields({
+        messageCode: "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_COMPLETED",
+      });
+    });
+  });
+
+  describe("When biometric session retrieval fails", () => {
+    describe("With retryable error", () => {
+      beforeEach(async () => {
+        dependencies.getBiometricSession =
+          mockGetBiometricSessionRetryableFailure;
+        try {
+          await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
+        } catch (error: unknown) {
+          lambdaError = error;
+        }
+      });
+
+      it("Throws RetainMessageOnQueue with appropriate message", () => {
+        expect(lambdaError).toBeInstanceOf(RetainMessageOnQueue);
+        expect((lambdaError as RetainMessageOnQueue).message).toMatch(
+          /Retryable error/,
+        );
+      });
+    });
+
+    describe("With non-retryable error", () => {
+      beforeEach(async () => {
+        dependencies.getBiometricSession =
+          mockGetBiometricSessionNonRetryableFailure;
+        await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
+      });
+
+      it("Sends error to IPV Core", () => {
+        expect(mockSuccessfulSendMessageToSqs).toHaveBeenCalledWith(
+          "mockIpvcoreOutboundSqs",
+          {
+            sub: "mockSubjectIdentifier",
+            state: "mockClientState",
+            error: "server_error",
+            error_description: "Internal server error",
+          },
+        );
+      });
+
+      it("Sends event to TxMA", () => {
+        expect(mockWriteGenericEventSuccessResult).toHaveBeenCalledWith(
+          expect.objectContaining({
+            eventName: "DCMAW_ASYNC_CRI_5XXERROR",
+            componentId: "mockIssuer",
+          }),
+        );
+      });
+
+      describe("When sending error to IPV Core fails", () => {
+        beforeEach(async () => {
+          dependencies.sendMessageToSqs = jest
+            .fn()
+            .mockResolvedValue(emptyFailure());
+          dependencies.getBiometricSession =
+            mockGetBiometricSessionNonRetryableFailure;
+          await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
+        });
+
+        it("Logs IPV Core message error", () => {
+          expect(consoleErrorSpy).toHaveBeenCalledWithLogFields({
+            messageCode:
+              "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_IPV_CORE_MESSAGE_ERROR",
+          });
+        });
+
+        it("Still sends event to TxMA", () => {
+          expect(mockWriteGenericEventSuccessResult).toHaveBeenCalled();
+        });
+        describe("Given writing TxMA event fails", () => {
+          beforeEach(async () => {
+            dependencies.getSessionRegistry = () => ({
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.test.ts
user: login: tmulberry-gds
body: I removed this
created_at: 2025-04-22T12:39:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -370,6 +414,185 @@ describe("Async Issue Biometric Credential", () => {
     });
   });
 
+  describe("When biometric session is not ready", () => {
+    beforeEach(async () => {
+      dependencies.getBiometricSession = mockGetBiometricSessionNotReady;
+      try {
+        await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
+      } catch (error: unknown) {
+        lambdaError = error;
+      }
+    });
+
+    it("Logs the appropriate message", () => {
+      expect(consoleInfoSpy).toHaveBeenCalledWithLogFields({
+        messageCode: "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_NOT_READY",
+        data: {
+          finish: "PROCESSING",
+        },
+      });
+    });
+
+    it("Throws RetainMessageOnQueue with appropriate message", () => {
+      expect(lambdaError).toBeInstanceOf(RetainMessageOnQueue);
+      expect((lambdaError as RetainMessageOnQueue).message).toMatch(
+        /Biometric session not ready: PROCESSING/,
+      );
+    });
+
+    it("Logs COMPLETED", () => {
+      expect(consoleInfoSpy).toHaveBeenCalledWithLogFields({
+        messageCode: "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_COMPLETED",
+      });
+    });
+  });
+
+  describe("When biometric session retrieval fails", () => {
+    describe("With retryable error", () => {
+      beforeEach(async () => {
+        dependencies.getBiometricSession =
+          mockGetBiometricSessionRetryableFailure;
+        try {
+          await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
+        } catch (error: unknown) {
+          lambdaError = error;
+        }
+      });
+
+      it("Throws RetainMessageOnQueue with appropriate message", () => {
+        expect(lambdaError).toBeInstanceOf(RetainMessageOnQueue);
+        expect((lambdaError as RetainMessageOnQueue).message).toMatch(
+          /Retryable error/,
+        );
+      });
+    });
+
+    describe("With non-retryable error", () => {
+      beforeEach(async () => {
+        dependencies.getBiometricSession =
+          mockGetBiometricSessionNonRetryableFailure;
+        await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
+      });
+
+      it("Sends error to IPV Core", () => {
+        expect(mockSuccessfulSendMessageToSqs).toHaveBeenCalledWith(
+          "mockIpvcoreOutboundSqs",
+          {
+            sub: "mockSubjectIdentifier",
+            state: "mockClientState",
+            error: "server_error",
+            error_description: "Internal server error",
+          },
+        );
+      });
+
+      it("Sends event to TxMA", () => {
+        expect(mockWriteGenericEventSuccessResult).toHaveBeenCalledWith(
+          expect.objectContaining({
+            eventName: "DCMAW_ASYNC_CRI_5XXERROR",
+            componentId: "mockIssuer",
+          }),
+        );
+      });
+
+      describe("When sending error to IPV Core fails", () => {
+        beforeEach(async () => {
+          dependencies.sendMessageToSqs = jest
+            .fn()
+            .mockResolvedValue(emptyFailure());
+          dependencies.getBiometricSession =
+            mockGetBiometricSessionNonRetryableFailure;
+          await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
+        });
+
+        it("Logs IPV Core message error", () => {
+          expect(consoleErrorSpy).toHaveBeenCalledWithLogFields({
+            messageCode:
+              "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_IPV_CORE_MESSAGE_ERROR",
+          });
+        });
+
+        it("Still sends event to TxMA", () => {
+          expect(mockWriteGenericEventSuccessResult).toHaveBeenCalled();
+        });
+        describe("Given writing TxMA event fails", () => {
+          beforeEach(async () => {
+            dependencies.getSessionRegistry = () => ({
+              ...mockInertSessionRegistry,
+              getSession: jest.fn().mockResolvedValue(
+                errorResult({
+                  errorType: GetSessionError.CLIENT_ERROR,
+                }),
+              ),
+            });
+            dependencies.getEventService = () => ({
+              ...mockInertEventService,
+              writeGenericEvent: jest.fn().mockResolvedValue(
+                errorResult({
+                  errorMessage: "mockError",
+                }),
+              ),
+            });
+
+            await lambdaHandlerConstructor(
+              dependencies,
+              validSqsEvent,
+              context,
+            );
+          });
+
+          it("Logs DCMAW_ASYNC_CRI_5XXERROR audit event error", () => {
+            expect(consoleErrorSpy).toHaveBeenCalledWithLogFields({
+              messageCode: "MOBILE_ASYNC_ERROR_WRITING_AUDIT_EVENT",
+              data: { auditEventName: "DCMAW_ASYNC_CRI_5XXERROR" },
+            });
+          });
+
+          it("Does not log COMPLETED", () => {
+            expect(consoleInfoSpy).not.toHaveBeenCalledWithLogFields({
+              messageCode: "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_COMPLETED",
+            });
+          });
+        });
+
+        describe("Given writing TxMA event succeeds", () => {
+          beforeEach(async () => {
+            dependencies.getSessionRegistry = () => ({
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.test.ts
user: login: tmulberry-gds
body: I removed this
created_at: 2025-04-22T12:39:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -75,6 +84,74 @@ export async function lambdaHandlerConstructor(
     throw new RetainMessageOnQueue("Failed to retrieve biometric viewer key");
   }
 
+  const viewerKey = viewerKeyResult.value;
+
+  const biometricSessionResult = await dependencies.getBiometricSession(
+    config.READID_BASE_URL,
+    sessionAttributes.sessionId,
+    viewerKey,
+  );
+
+  if (biometricSessionResult.isError) {
+    const eventService = dependencies.getEventService(config.TXMA_SQS);
+    const error: GetBiometricSessionError = biometricSessionResult.value;
+
+    // Check if the error was retryable based on error info
+    if (error.isRetryable) {
+      throw new RetainMessageOnQueue(
+        `Retryable error retrieving biometric session`,
+      );
+    }
+
+    const handleSendErrorMessageToOutboundQueueResponse =
+      await handleSendErrorMessageToOutboundQueue(
+        dependencies,
+        sessionAttributes,
+        config,
+        { error: "server_error", error_description: "Internal server error" },
+      );
+    if (handleSendErrorMessageToOutboundQueueResponse.isError) {
+      logger.error(
+        LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_IPV_CORE_MESSAGE_ERROR,
+      );
+    }
+
+    const writeEventResult = await eventService.writeGenericEvent({
+      eventName: "DCMAW_ASYNC_CRI_5XXERROR",
+      sub: sessionAttributes?.subjectIdentifier,
+      sessionId,
+      govukSigninJourneyId: sessionAttributes?.govukSigninJourneyId,
+      getNowInMilliseconds: Date.now,
+      componentId: config.ISSUER,
+      ipAddress: undefined,
+      txmaAuditEncoded: undefined,
+      redirect_uri: sessionAttributes?.redirectUri,
+      suspected_fraud_signal: undefined,
+    });
+
+    if (writeEventResult.isError) {
+      logger.error(LogMessage.ERROR_WRITING_AUDIT_EVENT);
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.ts
user: login: tmulberry-gds
body: updated it to be data: {
        auditEventName: "DCMAW_ASYNC_CRI_5XXERROR",
      },
created_at: 2025-04-22T13:37:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -370,6 +420,119 @@ describe("Async Issue Biometric Credential", () => {
     });
   });
 
+  describe("When biometric session is not ready", () => {
+    beforeEach(async () => {
+      dependencies.getBiometricSession = mockGetBiometricSessionNotReady;
+      try {
+        await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
+      } catch (error: unknown) {
+        lambdaError = error;
+      }
+    });
+
+    it("Logs the appropriate message", () => {
+      expect(consoleInfoSpy).toHaveBeenCalledWithLogFields({
+        messageCode: "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_NOT_READY",
+        data: {
+          finish: "PROCESSING",
+        },
+      });
+    });
+
+    it("Throws RetainMessageOnQueue with appropriate message", () => {
+      expect(lambdaError).toBeInstanceOf(RetainMessageOnQueue);
+      expect((lambdaError as RetainMessageOnQueue).message).toMatch(
+        /Biometric session not ready: PROCESSING/,
+      );
+    });
+  });
+
+  describe("When biometric session retrieval fails", () => {
+    describe("With retryable error", () => {
+      beforeEach(async () => {
+        dependencies.getBiometricSession =
+          mockGetBiometricSessionRetryableFailure;
+        try {
+          await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
+        } catch (error: unknown) {
+          lambdaError = error;
+        }
+      });
+
+      it("Logs retryable error", () => {
+        expect(consoleErrorSpy).toHaveBeenCalledWithLogFields({
+          messageCode:
+            "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_RETRYABLE_ERROR",
+        });
+      });
+
+      it("Throws RetainMessageOnQueue with appropriate message", () => {
+        expect(lambdaError).toBeInstanceOf(RetainMessageOnQueue);
+        expect((lambdaError as RetainMessageOnQueue).message).toMatch(
+          /Retryable error/,
+        );
+      });
+    });
+
+    describe("With non-retryable error", () => {
+      beforeEach(async () => {
+        dependencies.getBiometricSession =
+          mockGetBiometricSessionNonRetryableFailure;
+        await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
+      });
+
+      it("Logs non-retryable error", () => {
+        expect(consoleErrorSpy).toHaveBeenCalledWithLogFields({
+          messageCode:
+            "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_NON_RETRYABLE_ERROR",
+        });
+      });
+
+      it("Sends error to IPV Core", () => {
+        expect(mockSuccessfulSendMessageToSqs).toHaveBeenCalledWith(
+          "mockIpvcoreOutboundSqs",
+          {
+            sub: expect.any(String),
+            state: expect.any(String),
+            error: "server_error",
+            error_description:
+              "Failed to retrieve biometric session from ReadID",
+          },
+        );
+      });
+
+      it("Sends event to TxMA", () => {
+        expect(mockWriteGenericEventSuccessResult).toHaveBeenCalledWith(
+          expect.objectContaining({
+            eventName: "DCMAW_ASYNC_CRI_5XXERROR",
+            componentId: "mockIssuer",
+          }),
+        );
+      });
+
+      describe("When sending error to IPV Core fails", () => {
+        beforeEach(async () => {
+          dependencies.sendMessageToSqs = jest
+            .fn()
+            .mockResolvedValue(emptyFailure());
+          dependencies.getBiometricSession =
+            mockGetBiometricSessionNonRetryableFailure;
+          await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
+        });
+
+        it("Logs IPV Core message error", () => {
+          expect(consoleErrorSpy).toHaveBeenCalledWithLogFields({
+            messageCode:
+              "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_IPV_CORE_MESSAGE_ERROR",
+          });
+        });
+
+        it("Still sends event to TxMA", () => {
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.test.ts
user: login: tmulberry-gds
body: Yes. Updated to match
created_at: 2025-04-22T14:32:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,152 @@
+import { logger } from "../../common/logging/logger";
+import { LogMessage } from "../../common/logging/LogMessage";
+import {
+  HttpError,
+  ISendHttpRequest,
+  RetryConfig,
+  sendHttpRequest as sendHttpRequestDefault,
+  SuccessfulHttpResponse,
+} from "../../adapters/http/sendHttpRequest";
+import { Result, successResult, errorResult } from "../../utils/result";
+
+export interface BiometricSession {
+  finish: string;
+}
+
+export interface GetBiometricSessionError {
+  isRetryable: boolean;
+}
+
+export type GetBiometricSession = (
+  baseUrl: string,
+  biometricSessionId: string,
+  viewerKey: string,
+  sendHttpRequest?: ISendHttpRequest,
+) => Promise<Result<BiometricSession, GetBiometricSessionError>>;
+
+export const getBiometricSession: GetBiometricSession = async (
+  baseUrl: string,
+  biometricSessionId: string,
+  viewerKey: string,
+  sendHttpRequest: ISendHttpRequest = sendHttpRequestDefault,
+) => {
+  const httpRequest = {
+    url: `${baseUrl}/odata/v1/ODataServlet/Sessions('${biometricSessionId}')`,
+    method: "GET" as const,
+    headers: {
+      "X-Innovalor-Authorization": viewerKey,
+      Accept: "application/json;odata.metadata=minimal",
+      "Content-Type": "application/json;odata.metadata=minimal",
+      "OData-MaxVersion": "4.0",
+      "OData-Version": "4.0",
+    },
+  };
+
+  const retryConfig: RetryConfig = {
+    retryableStatusCodes: [
+      429, 500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511,
+    ],
+    delayInMillis: 50,
+  };
+
+  logger.debug(LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_ATTEMPT, {
+    data: {
+      biometricSessionId,
+    },
+  });
+
+  const getBiometricSessionResult: Result<SuccessfulHttpResponse, HttpError> =
+    await sendHttpRequest(httpRequest, retryConfig);
+
+  if (getBiometricSessionResult.isError) {
+    const error = getBiometricSessionResult.value;
+    const statusCode = error.statusCode ? Number(error.statusCode) : undefined;
+    const retryableStatusCodes = retryConfig.retryableStatusCodes;
+
+    const isRetryable =
+      !statusCode || (retryableStatusCodes?.includes(statusCode) ?? false);
+
+    logger.error(
+      LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE,
+      {
+        data: {
+          error,
+          isRetryable,
+          biometricSessionId,
+        },
+      },
+    );
+
+    return errorResult({
+      statusCode,
+      message: "Failed to retrieve biometric session",
+      isRetryable,
+    });
+  }
+
+  const getBiometricSessionResponse = getBiometricSessionResult.value;
+
+  if (getBiometricSessionResponse?.body == null) {
+    logger.error(
+      LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE,
+      {
+        data: {
+          getBiometricSessionResponse,
+          biometricSessionId,
+        },
+      },
+    );
+
+    return errorResult({
+      message: "Empty response body from ReadID",
+      isRetryable: false,
+    });
+  }
+
+  let parsedBody;
+  try {
+    parsedBody = JSON.parse(getBiometricSessionResponse.body);
+  } catch (error) {
+    logger.error(
+      LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE,
+      {
+        data: {
+          error,
+          biometricSessionId,
+        },
+      },
+    );
+
+    return errorResult({
+      message: "Failed to parse response JSON",
+      isRetryable: false,
+    });
+  }
+
+  // Check if the response has the expected structure
+  if (!parsedBody || typeof parsedBody.finish !== "string") {
+    logger.error(
+      LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE,
+      {
+        data: {
+          validFinishProperty: parsedBody?.finish,
path: backend-api/src/functions/asyncIssueBiometricCredential/getBiometricSession/getBiometricSession.ts
user: login: tmulberry-gds
body: Changed to 'invalidFinishProperty'
created_at: 2025-04-23T12:09:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,334 @@
+import { errorResult, Result, successResult } from "../../utils/result";
+import {
+  getBiometricSession,
+  BiometricSession,
+  GetBiometricSessionError,
+} from "./getBiometricSession";
+import { expect } from "@jest/globals";
+import "../../../../tests/testUtils/matchers";
+import { ISendHttpRequest } from "../../adapters/http/sendHttpRequest";
+
+describe("getBiometricSession", () => {
+  let result: Result<BiometricSession, GetBiometricSessionError>;
+  let consoleDebugSpy: jest.SpyInstance;
+  let consoleErrorSpy: jest.SpyInstance;
+  let mockSendHttpRequest: ISendHttpRequest;
+  const mockSessionId = "mockSessionId";
+
+  const expectedHttpRequest = {
+    url: "https://mockUrl.com/odata/v1/ODataServlet/Sessions('mockSessionId')",
+    method: "GET",
+    headers: {
+      "X-Innovalor-Authorization": "mockSubmitterKey",
+      Accept: "application/json;odata.metadata=minimal",
+      "Content-Type": "application/json;odata.metadata=minimal",
+      "OData-MaxVersion": "4.0",
+      "OData-Version": "4.0",
+    },
+  };
+
+  const expectedRetryConfig = {
+    retryableStatusCodes: [
+      429, 500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511,
+    ],
+    delayInMillis: 50,
+  };
+
+  beforeEach(() => {
+    consoleDebugSpy = jest.spyOn(console, "debug");
+    consoleErrorSpy = jest.spyOn(console, "error");
+
+    jest.resetModules();
+  });
+
+  describe("On every call", () => {
+    beforeEach(async () => {
+      mockSendHttpRequest = jest.fn().mockResolvedValue(
+        successResult({
+          statusCode: 200,
+          body: JSON.stringify({
+            id: mockSessionId,
path: backend-api/src/functions/asyncIssueBiometricCredential/getBiometricSession/getBiometricSession.test.ts
user: login: tmulberry-gds
body: Removed Ids
created_at: 2025-04-23T12:10:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,334 @@
+import { errorResult, Result, successResult } from "../../utils/result";
+import {
+  getBiometricSession,
+  BiometricSession,
+  GetBiometricSessionError,
+} from "./getBiometricSession";
+import { expect } from "@jest/globals";
+import "../../../../tests/testUtils/matchers";
+import { ISendHttpRequest } from "../../adapters/http/sendHttpRequest";
+
+describe("getBiometricSession", () => {
+  let result: Result<BiometricSession, GetBiometricSessionError>;
+  let consoleDebugSpy: jest.SpyInstance;
+  let consoleErrorSpy: jest.SpyInstance;
+  let mockSendHttpRequest: ISendHttpRequest;
+  const mockSessionId = "mockSessionId";
+
+  const expectedHttpRequest = {
+    url: "https://mockUrl.com/odata/v1/ODataServlet/Sessions('mockSessionId')",
+    method: "GET",
+    headers: {
+      "X-Innovalor-Authorization": "mockSubmitterKey",
+      Accept: "application/json;odata.metadata=minimal",
+      "Content-Type": "application/json;odata.metadata=minimal",
+      "OData-MaxVersion": "4.0",
+      "OData-Version": "4.0",
+    },
+  };
+
+  const expectedRetryConfig = {
+    retryableStatusCodes: [
+      429, 500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511,
+    ],
+    delayInMillis: 50,
+  };
+
+  beforeEach(() => {
+    consoleDebugSpy = jest.spyOn(console, "debug");
+    consoleErrorSpy = jest.spyOn(console, "error");
+
+    jest.resetModules();
+  });
+
+  describe("On every call", () => {
+    beforeEach(async () => {
+      mockSendHttpRequest = jest.fn().mockResolvedValue(
+        successResult({
+          statusCode: 200,
+          body: JSON.stringify({
+            id: mockSessionId,
+            finish: "DONE",
+          }),
+          headers: {
+            mockHeaderKey: "mockHeaderValue",
+          },
+        }),
+      );
+
+      result = await getBiometricSession(
+        "https://mockUrl.com",
+        mockSessionId,
+        "mockSubmitterKey",
+        mockSendHttpRequest,
+      );
+    });
+
+    it("Logs network call attempt at debug level", () => {
+      expect(consoleDebugSpy).toHaveBeenCalledWithLogFields({
+        messageCode:
+          "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_ATTEMPT",
+      });
+    });
+  });
+
+  describe("Given an error is returned when requesting session", () => {
+    describe("Given a retryable error (5XX)", () => {
+      beforeEach(async () => {
+        mockSendHttpRequest = jest.fn().mockResolvedValue(
+          errorResult({
+            statusCode: 503,
+            message: "Service Unavailable",
+          }),
+        );
+        result = await getBiometricSession(
+          "https://mockUrl.com",
+          mockSessionId,
+          "mockSubmitterKey",
+          mockSendHttpRequest,
+        );
+      });
+
+      it("Logs error", () => {
+        expect(consoleErrorSpy).toHaveBeenCalledWithLogFields({
+          messageCode:
+            "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE",
+        });
+      });
+
+      it("Returns an error result with retryable flag", () => {
+        expect(result.isError).toBe(true);
+        expect(result.value).toEqual({
+          statusCode: 503,
+          message: "Failed to retrieve biometric session",
+          isRetryable: true,
+        });
+        expect(mockSendHttpRequest).toBeCalledWith(
+          expectedHttpRequest,
+          expectedRetryConfig,
+        );
+      });
+    });
+
+    describe("Given a non-retryable error (4XX)", () => {
+      beforeEach(async () => {
+        mockSendHttpRequest = jest.fn().mockResolvedValue(
+          errorResult({
+            statusCode: 404,
+            message: "Not Found",
+          }),
+        );
+        result = await getBiometricSession(
+          "https://mockUrl.com",
+          mockSessionId,
+          "mockSubmitterKey",
+          mockSendHttpRequest,
+        );
+      });
+
+      it("Logs error", () => {
+        expect(consoleErrorSpy).toHaveBeenCalledWithLogFields({
+          messageCode:
+            "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE",
+        });
+      });
+
+      it("Returns an error result with non-retryable flag", () => {
+        expect(result.isError).toBe(true);
+        expect(result.value).toEqual({
+          statusCode: 404,
+          message: "Failed to retrieve biometric session",
+          isRetryable: false,
+        });
+        expect(mockSendHttpRequest).toBeCalledWith(
+          expectedHttpRequest,
+          expectedRetryConfig,
+        );
+      });
+    });
+
+    describe("Given a network error (no status code)", () => {
+      beforeEach(async () => {
+        mockSendHttpRequest = jest.fn().mockResolvedValue(
+          errorResult({
+            message: "Network Error",
+          }),
+        );
+        result = await getBiometricSession(
+          "https://mockUrl.com",
+          mockSessionId,
+          "mockSubmitterKey",
+          mockSendHttpRequest,
+        );
+      });
+
+      it("Treats it as retryable", () => {
+        expect(result.isError).toBe(true);
+        expect(result.value).toEqual({
+          statusCode: undefined,
+          message: "Failed to retrieve biometric session",
+          isRetryable: true,
+        });
+      });
+    });
+  });
+
+  describe("Given the response is invalid", () => {
+    describe("Given response body is undefined", () => {
+      beforeEach(async () => {
+        mockSendHttpRequest = jest.fn().mockResolvedValue(
+          successResult({
+            statusCode: 200,
+            body: undefined,
+            headers: {
+              mockHeaderKey: "mockHeaderValue",
+            },
+          }),
+        );
+
+        result = await getBiometricSession(
+          "https://mockUrl.com",
+          mockSessionId,
+          "mockSubmitterKey",
+          mockSendHttpRequest,
+        );
+      });
+
+      it("Logs error", () => {
+        expect(consoleErrorSpy).toHaveBeenCalledWithLogFields({
+          messageCode:
+            "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE",
+        });
+      });
+
+      it("Returns an error result with retryable flag", () => {
path: backend-api/src/functions/asyncIssueBiometricCredential/getBiometricSession/getBiometricSession.test.ts
user: login: tmulberry-gds
body: updated to say 'set to false'
created_at: 2025-04-23T12:10:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,334 @@
+import { errorResult, Result, successResult } from "../../utils/result";
+import {
+  getBiometricSession,
+  BiometricSession,
+  GetBiometricSessionError,
+} from "./getBiometricSession";
+import { expect } from "@jest/globals";
+import "../../../../tests/testUtils/matchers";
+import { ISendHttpRequest } from "../../adapters/http/sendHttpRequest";
+
+describe("getBiometricSession", () => {
+  let result: Result<BiometricSession, GetBiometricSessionError>;
+  let consoleDebugSpy: jest.SpyInstance;
+  let consoleErrorSpy: jest.SpyInstance;
+  let mockSendHttpRequest: ISendHttpRequest;
+  const mockSessionId = "mockSessionId";
+
+  const expectedHttpRequest = {
+    url: "https://mockUrl.com/odata/v1/ODataServlet/Sessions('mockSessionId')",
+    method: "GET",
+    headers: {
+      "X-Innovalor-Authorization": "mockSubmitterKey",
+      Accept: "application/json;odata.metadata=minimal",
+      "Content-Type": "application/json;odata.metadata=minimal",
+      "OData-MaxVersion": "4.0",
+      "OData-Version": "4.0",
+    },
+  };
+
+  const expectedRetryConfig = {
+    retryableStatusCodes: [
+      429, 500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511,
+    ],
+    delayInMillis: 50,
+  };
+
+  beforeEach(() => {
+    consoleDebugSpy = jest.spyOn(console, "debug");
+    consoleErrorSpy = jest.spyOn(console, "error");
+
+    jest.resetModules();
+  });
+
+  describe("On every call", () => {
+    beforeEach(async () => {
+      mockSendHttpRequest = jest.fn().mockResolvedValue(
+        successResult({
+          statusCode: 200,
+          body: JSON.stringify({
+            id: mockSessionId,
+            finish: "DONE",
+          }),
+          headers: {
+            mockHeaderKey: "mockHeaderValue",
+          },
+        }),
+      );
+
+      result = await getBiometricSession(
+        "https://mockUrl.com",
+        mockSessionId,
+        "mockSubmitterKey",
+        mockSendHttpRequest,
+      );
+    });
+
+    it("Logs network call attempt at debug level", () => {
+      expect(consoleDebugSpy).toHaveBeenCalledWithLogFields({
+        messageCode:
+          "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_ATTEMPT",
+      });
+    });
+  });
+
+  describe("Given an error is returned when requesting session", () => {
+    describe("Given a retryable error (5XX)", () => {
+      beforeEach(async () => {
+        mockSendHttpRequest = jest.fn().mockResolvedValue(
+          errorResult({
+            statusCode: 503,
+            message: "Service Unavailable",
+          }),
+        );
+        result = await getBiometricSession(
+          "https://mockUrl.com",
+          mockSessionId,
+          "mockSubmitterKey",
+          mockSendHttpRequest,
+        );
+      });
+
+      it("Logs error", () => {
+        expect(consoleErrorSpy).toHaveBeenCalledWithLogFields({
+          messageCode:
+            "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE",
+        });
+      });
+
+      it("Returns an error result with retryable flag", () => {
+        expect(result.isError).toBe(true);
+        expect(result.value).toEqual({
+          statusCode: 503,
+          message: "Failed to retrieve biometric session",
+          isRetryable: true,
+        });
+        expect(mockSendHttpRequest).toBeCalledWith(
+          expectedHttpRequest,
+          expectedRetryConfig,
+        );
+      });
+    });
+
+    describe("Given a non-retryable error (4XX)", () => {
+      beforeEach(async () => {
+        mockSendHttpRequest = jest.fn().mockResolvedValue(
+          errorResult({
+            statusCode: 404,
+            message: "Not Found",
+          }),
+        );
+        result = await getBiometricSession(
+          "https://mockUrl.com",
+          mockSessionId,
+          "mockSubmitterKey",
+          mockSendHttpRequest,
+        );
+      });
+
+      it("Logs error", () => {
+        expect(consoleErrorSpy).toHaveBeenCalledWithLogFields({
+          messageCode:
+            "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE",
+        });
+      });
+
+      it("Returns an error result with non-retryable flag", () => {
+        expect(result.isError).toBe(true);
+        expect(result.value).toEqual({
+          statusCode: 404,
+          message: "Failed to retrieve biometric session",
+          isRetryable: false,
+        });
+        expect(mockSendHttpRequest).toBeCalledWith(
+          expectedHttpRequest,
+          expectedRetryConfig,
+        );
+      });
+    });
+
+    describe("Given a network error (no status code)", () => {
+      beforeEach(async () => {
+        mockSendHttpRequest = jest.fn().mockResolvedValue(
+          errorResult({
+            message: "Network Error",
+          }),
+        );
+        result = await getBiometricSession(
+          "https://mockUrl.com",
+          mockSessionId,
+          "mockSubmitterKey",
+          mockSendHttpRequest,
+        );
+      });
+
+      it("Treats it as retryable", () => {
+        expect(result.isError).toBe(true);
+        expect(result.value).toEqual({
+          statusCode: undefined,
+          message: "Failed to retrieve biometric session",
+          isRetryable: true,
+        });
+      });
+    });
+  });
+
+  describe("Given the response is invalid", () => {
+    describe("Given response body is undefined", () => {
+      beforeEach(async () => {
+        mockSendHttpRequest = jest.fn().mockResolvedValue(
+          successResult({
+            statusCode: 200,
+            body: undefined,
+            headers: {
+              mockHeaderKey: "mockHeaderValue",
+            },
+          }),
+        );
+
+        result = await getBiometricSession(
+          "https://mockUrl.com",
+          mockSessionId,
+          "mockSubmitterKey",
+          mockSendHttpRequest,
+        );
+      });
+
+      it("Logs error", () => {
+        expect(consoleErrorSpy).toHaveBeenCalledWithLogFields({
+          messageCode:
+            "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE",
+        });
+      });
+
+      it("Returns an error result with retryable flag", () => {
+        expect(result.isError).toBe(true);
+        expect(result.value).toEqual({
+          message: "Empty response body from ReadID",
+          isRetryable: false,
+        });
+        expect(mockSendHttpRequest).toBeCalledWith(
+          expectedHttpRequest,
+          expectedRetryConfig,
+        );
+      });
+    });
+
+    describe("Given response body cannot be parsed", () => {
+      beforeEach(async () => {
+        mockSendHttpRequest = jest.fn().mockResolvedValue(
+          successResult({
+            statusCode: 200,
+            body: "Invalid JSON",
+            headers: {
+              mockHeaderKey: "mockHeaderValue",
+            },
+          }),
+        );
+
+        result = await getBiometricSession(
+          "https://mockUrl.com",
+          mockSessionId,
+          "mockSubmitterKey",
+          mockSendHttpRequest,
+        );
+      });
+
+      it("Logs error", () => {
+        expect(consoleErrorSpy).toHaveBeenCalledWithLogFields({
+          messageCode:
+            "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_FAILURE",
+        });
+      });
+
+      it("Returns an error result with retryable flag", () => {
path: backend-api/src/functions/asyncIssueBiometricCredential/getBiometricSession/getBiometricSession.test.ts
user: login: tmulberry-gds
body: updated to say 'set to false'
created_at: 2025-04-23T12:10:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -386,30 +534,37 @@ describe("Async Issue Biometric Credential", () => {
         expect(mockGetSecretsSuccess).not.toHaveBeenCalled();
       });
 
-      it("Logs COMPLETED with sessionId", async () => {
+      it("Logs COMPLETED", async () => {
         expect(consoleInfoSpy).toHaveBeenCalledWithLogFields({
           messageCode: "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_COMPLETED",
-          sessionId: mockSessionId,
         });
       });
     });
 
-    describe("Given sessionState is ASYNC_BIOMETRIC_SESSION_FINISHED", () => {
-      beforeEach(async () => {
-        await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
+    it("Passes correct arguments to get biometric session", async () => {
+      await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
+      expect(mockGetBiometricSessionSuccess).toHaveBeenCalledWith(
+        "mockReadIdBaseUrl",
+        mockSessionId,
+        "mockViewerKey",
+      );
+    });
+
+    it("Logs COMPLETED", async () => {
+      await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
+      expect(consoleInfoSpy).toHaveBeenCalledWithLogFields({
+        messageCode: "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_COMPLETED",
       });
+    });
 
-      it("Passes correct arguments to get secrets", () => {
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.test.ts
user: login: tmulberry-gds
body: Added it back in
created_at: 2025-04-23T12:11:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,136 @@
+import { logger } from "../../common/logging/logger";
+import { LogMessage } from "../../common/logging/LogMessage";
+import {
+  HttpError,
+  ISendHttpRequest,
+  sendHttpRequest as sendHttpRequestDefault,
+  SuccessfulHttpResponse,
+} from "../../adapters/http/sendHttpRequest";
+import { emptyFailure, Result, successResult } from "../../utils/result";
+
+export interface BiometricSession {
+  id: string;
+  finish: string;
+}
+
+let lastError: HttpError | null = null;
+
+export function getLastError(): HttpError | null {
+  return lastError;
+}
+
+export function isRetryableError(): boolean {
+  if (!lastError || lastError.statusCode === undefined) {
+    return false;
+  }
+
+  const retryableStatusCodes = [
+    429, 500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511,
+  ];
+
+  return retryableStatusCodes.includes(lastError.statusCode);
+}
+
+export type GetBiometricSession = (
+  baseUrl: string,
+  sessionId: string,
+  submitterKey: string,
+  sendHttpRequest?: ISendHttpRequest,
+) => Promise<Result<BiometricSession, void>>;
+
+export const getBiometricSession: GetBiometricSession = async (
+  baseUrl: string,
+  sessionId: string,
+  submitterKey: string,
+  sendHttpRequest: ISendHttpRequest = sendHttpRequestDefault,
+) => {
+  const httpRequest = {
+    url: `${baseUrl}/odata/v1/ODataServlet/Sessions('${sessionId}')`,
+    method: "GET" as const,
+    headers: {
+      "X-Innovalor-Authorization": submitterKey,
+      Accept: "application/json;odata.metadata=minimal",
+      "Content-Type": "application/json;odata.metadata=minimal",
+      "OData-MaxVersion": "4.0",
+      "OData-Version": "4.0",
+    },
+  };
+  const httpRequestLogData = {
path: backend-api/src/functions/asyncIssueBiometricCredential/getBiometricSession/getBiometricSession.ts
user: login: tmulberry-gds
body: This was the reason. Pushed changes
created_at: 2025-04-23T12:12:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -370,6 +417,153 @@ describe("Async Issue Biometric Credential", () => {
     });
   });
 
+  describe("When biometric session is not ready", () => {
+    beforeEach(() => {
+      dependencies.getBiometricSession = mockGetBiometricSessionNotReady;
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.test.ts
user: login: tmulberry-gds
body: Resolved
created_at: 2025-04-23T12:12:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,157 @@
+import { logger } from "../../common/logging/logger";
+import { LogMessage } from "../../common/logging/LogMessage";
+import {
+  HttpError,
+  ISendHttpRequest,
+  RetryConfig,
+  sendHttpRequest as sendHttpRequestDefault,
+  SuccessfulHttpResponse,
+} from "../../adapters/http/sendHttpRequest";
+import { Result, successResult, errorResult } from "../../utils/result";
+
+export interface BiometricSession {
path: backend-api/src/functions/asyncIssueBiometricCredential/getBiometricSession/getBiometricSession.ts
user: login: tmulberry-gds
body: I removed the id dield
created_at: 2025-04-23T12:14:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,146 @@
+import { logger } from "../../common/logging/logger";
+import { LogMessage } from "../../common/logging/LogMessage";
+import {
+  HttpError,
+  ISendHttpRequest,
+  sendHttpRequest as sendHttpRequestDefault,
+  SuccessfulHttpResponse,
+} from "../../adapters/http/sendHttpRequest";
+import { Result, successResult, errorResult } from "../../utils/result";
+
+export interface BiometricSession {
+  finish: string;
+}
+
+export interface GetBiometricSessionError {
+  isRetryable: boolean;
+}
+
+export type GetBiometricSession = (
+  baseUrl: string,
+  biometricSessionId: string,
+  viewerKey: string,
+  sendHttpRequest?: ISendHttpRequest,
+) => Promise<Result<BiometricSession, GetBiometricSessionError>>;
+
+export const getBiometricSession: GetBiometricSession = async (
+  baseUrl: string,
+  biometricSessionId: string,
+  viewerKey: string,
+  sendHttpRequest: ISendHttpRequest = sendHttpRequestDefault,
+) => {
+  const httpRequest = {
+    url: `${baseUrl}/odata/v1/ODataServlet/Sessions('${biometricSessionId}')`,
+    method: "GET" as const,
+    headers: {
+      "X-Innovalor-Authorization": viewerKey,
+      Accept: "application/json;odata.metadata=minimal",
+      "Content-Type": "application/json;odata.metadata=minimal",
+      "OData-MaxVersion": "4.0",
+      "OData-Version": "4.0",
+    },
+  };
+
+  const retryConfig = {
+    retryableStatusCodes: [
+      429, 500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511,
+    ],
+    delayInMillis: 50,
+  };
+
+  logger.debug(LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_ATTEMPT, {
+    data: {
+      biometricSessionId,
path: backend-api/src/functions/asyncIssueBiometricCredential/getBiometricSession/getBiometricSession.ts
user: login: chris-cooksley-gds
body: Nitpick (also applies below) - we've already appended the biometricSessionId to the logs in the handler, so this is redundant information.
created_at: 2025-04-23T13:39:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,157 @@
+import { logger } from "../../common/logging/logger";
+import { LogMessage } from "../../common/logging/LogMessage";
+import {
+  HttpError,
+  ISendHttpRequest,
+  RetryConfig,
+  sendHttpRequest as sendHttpRequestDefault,
+  SuccessfulHttpResponse,
+} from "../../adapters/http/sendHttpRequest";
+import { Result, successResult, errorResult } from "../../utils/result";
+
+export interface BiometricSession {
+  id: string;
+  finish: string;
+}
+
+export interface BiometricSessionError {
+  statusCode?: number;
+  message: string;
+  isRetryable: boolean;
+}
+
+export type GetBiometricSession = (
+  baseUrl: string,
+  biometricSessionId: string,
+  viewerKey: string,
+  sendHttpRequest?: ISendHttpRequest,
+) => Promise<Result<BiometricSession, BiometricSessionError>>;
+
+export const getBiometricSession: GetBiometricSession = async (
+  baseUrl: string,
+  biometricSessionId: string,
+  viewerKey: string,
+  sendHttpRequest: ISendHttpRequest = sendHttpRequestDefault,
+) => {
+  const httpRequest = {
+    url: `${baseUrl}/odata/v1/ODataServlet/Sessions('${biometricSessionId}')`,
+    method: "GET" as const,
+    headers: {
+      "X-Innovalor-Authorization": viewerKey,
+      Accept: "application/json;odata.metadata=minimal",
+      "Content-Type": "application/json;odata.metadata=minimal",
+      "OData-MaxVersion": "4.0",
+      "OData-Version": "4.0",
+    },
+  };
+
+  const retryConfig: RetryConfig = {
+    retryableStatusCodes: [
+      429, 500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511,
+    ],
+    maxAttempts: 3,
+    delayInMillis: 50,
+  };
+
+  logger.debug(LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_ATTEMPT, {
+    data: {
+      biometricSessionId,
+    },
+  });
+
+  const getBiometricSessionResult: Result<SuccessfulHttpResponse, HttpError> =
+    await sendHttpRequest(httpRequest, retryConfig);
+
+  if (getBiometricSessionResult.isError) {
path: backend-api/src/functions/asyncIssueBiometricCredential/getBiometricSession/getBiometricSession.ts
user: login: chris-cooksley-gds
body: I think readability trumps consistency here (per our values/principles), but it's non-blocking - let's leave for a future refactor.
created_at: 2025-04-23T13:40:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -74,7 +79,90 @@ export async function lambdaHandlerConstructor(
   if (viewerKeyResult.isError) {
     throw new RetainMessageOnQueue("Failed to retrieve biometric viewer key");
   }
+  const viewerKey = viewerKeyResult.value;
+
+  const biometricSessionResult = await dependencies.getBiometricSession(
+    config.READID_BASE_URL,
+    sessionId,
+    viewerKey,
+  );
+
+  if (biometricSessionResult.isError) {
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.ts
user: login: chris-cooksley-gds
body: I'd lean towards extracting the full error handling for this scenario into a helper, too - but not a priority at this stage so we can leave this for a future refactor.
created_at: 2025-04-23T13:41:04Z
author_association: CONTRIBUTOR
path: backend-api/infra/functions/issueBiometricCredential.yaml
user: login: chris-cooksley-gds
body: OK. I think ideally we'd want to test this through the readid mock, so that we know that we actually handle retryable failures over the network in the way we expect. But, I'm not sure how easy that is (or if it's even possible), so I'm happy with this workaround for now.
created_at: 2025-04-23T13:42:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -386,30 +609,43 @@ describe("Async Issue Biometric Credential", () => {
         expect(mockGetSecretsSuccess).not.toHaveBeenCalled();
       });
 
-      it("Logs COMPLETED with sessionId", async () => {
+      it("Logs COMPLETED", async () => {
         expect(consoleInfoSpy).toHaveBeenCalledWithLogFields({
           messageCode: "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_COMPLETED",
-          sessionId: mockSessionId,
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.test.ts
user: login: chris-cooksley-gds
body: Github's showing this as being removed, still 
![image](https://github.com/user-attachments/assets/1866f693-9469-455f-a8ce-90b0cca23a17)

created_at: 2025-04-23T13:44:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -386,30 +609,43 @@ describe("Async Issue Biometric Credential", () => {
         expect(mockGetSecretsSuccess).not.toHaveBeenCalled();
       });
 
-      it("Logs COMPLETED with sessionId", async () => {
+      it("Logs COMPLETED", async () => {
         expect(consoleInfoSpy).toHaveBeenCalledWithLogFields({
           messageCode: "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_COMPLETED",
-          sessionId: mockSessionId,
         });
       });
     });
 
-    describe("Given sessionState is ASYNC_BIOMETRIC_SESSION_FINISHED", () => {
+    it("Passes correct arguments to get biometric session", async () => {
+      await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
+      expect(mockGetBiometricSessionSuccess).toHaveBeenCalledWith(
+        "mockReadIdBaseUrl",
+        mockSessionId,
+        "mockViewerKey",
+      );
+    });
+
+    it("Logs COMPLETED", async () => {
+      await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
+      expect(consoleInfoSpy).toHaveBeenCalledWithLogFields({
+        messageCode: "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_COMPLETED",
+      });
+    });
+
+    describe("Given sessionState is ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_FINISHED", () => {
       beforeEach(async () => {
         await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
       });
-
-      it("Passes correct arguments to get secrets", () => {
+      it("Passes correct arguments to get secrets", async () => {
         expect(mockGetSecretsSuccess).toHaveBeenCalledWith({
           secretNames: ["mockBiometricViewerAccessKey"],
           cacheDurationInSeconds: 900,
         });
       });
 
-      it("Logs COMPLETED with sessionId", async () => {
+      it("Logs COMPLETED", async () => {
         expect(consoleInfoSpy).toHaveBeenCalledWithLogFields({
           messageCode: "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_COMPLETED",
-          sessionId: mockSessionId,
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.test.ts
user: login: chris-cooksley-gds
body: Github's showing this as being removed, still. 
![image](https://github.com/user-attachments/assets/09286ef4-c38e-4f5a-82fe-8de54050215a)
created_at: 2025-04-23T13:45:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -386,30 +609,43 @@ describe("Async Issue Biometric Credential", () => {
         expect(mockGetSecretsSuccess).not.toHaveBeenCalled();
       });
 
-      it("Logs COMPLETED with sessionId", async () => {
+      it("Logs COMPLETED", async () => {
         expect(consoleInfoSpy).toHaveBeenCalledWithLogFields({
           messageCode: "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_COMPLETED",
-          sessionId: mockSessionId,
         });
       });
     });
 
-    describe("Given sessionState is ASYNC_BIOMETRIC_SESSION_FINISHED", () => {
+    it("Passes correct arguments to get biometric session", async () => {
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.test.ts
user: login: chris-cooksley-gds
body: My comment wasn't clear - I think the scenarios highlighted in the below screenshot _should_ be nested within `Given sessionState is ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_FINISHED`. I guess the 'Logs COMPLETED' scenario can just be removed as a duplicate.

Currently, there are only two valid scenarios (session state either RESULT_SENT or ISSUE_BIOMETRIC_CREDENTIAL_FINISHED) and I think every test case should therefore be nested within one of those, rather than 'loose' at the top level.
created_at: 2025-04-23T13:48:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -386,30 +609,43 @@ describe("Async Issue Biometric Credential", () => {
         expect(mockGetSecretsSuccess).not.toHaveBeenCalled();
       });
 
-      it("Logs COMPLETED with sessionId", async () => {
+      it("Logs COMPLETED", async () => {
         expect(consoleInfoSpy).toHaveBeenCalledWithLogFields({
           messageCode: "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_COMPLETED",
-          sessionId: mockSessionId,
         });
       });
     });
 
-    describe("Given sessionState is ASYNC_BIOMETRIC_SESSION_FINISHED", () => {
+    it("Passes correct arguments to get biometric session", async () => {
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.test.ts
user: login: chris-cooksley-gds
body: ![image](https://github.com/user-attachments/assets/44b61a4b-a532-48ce-9059-2ceaadc487d0)


created_at: 2025-04-23T13:48:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,146 @@
+import { logger } from "../../common/logging/logger";
+import { LogMessage } from "../../common/logging/LogMessage";
+import {
+  HttpError,
+  ISendHttpRequest,
+  sendHttpRequest as sendHttpRequestDefault,
+  SuccessfulHttpResponse,
+} from "../../adapters/http/sendHttpRequest";
+import { Result, successResult, errorResult } from "../../utils/result";
+
+export interface BiometricSession {
+  finish: string;
+}
+
+export interface GetBiometricSessionError {
+  isRetryable: boolean;
+}
+
+export type GetBiometricSession = (
+  baseUrl: string,
+  biometricSessionId: string,
+  viewerKey: string,
+  sendHttpRequest?: ISendHttpRequest,
+) => Promise<Result<BiometricSession, GetBiometricSessionError>>;
+
+export const getBiometricSession: GetBiometricSession = async (
+  baseUrl: string,
+  biometricSessionId: string,
+  viewerKey: string,
+  sendHttpRequest: ISendHttpRequest = sendHttpRequestDefault,
+) => {
+  const httpRequest = {
+    url: `${baseUrl}/odata/v1/ODataServlet/Sessions('${biometricSessionId}')`,
+    method: "GET" as const,
+    headers: {
+      "X-Innovalor-Authorization": viewerKey,
+      Accept: "application/json;odata.metadata=minimal",
+      "Content-Type": "application/json;odata.metadata=minimal",
+      "OData-MaxVersion": "4.0",
+      "OData-Version": "4.0",
+    },
+  };
+
+  const retryConfig = {
+    retryableStatusCodes: [
+      429, 500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511,
+    ],
+    delayInMillis: 50,
+  };
+
+  logger.debug(LogMessage.ISSUE_BIOMETRIC_CREDENTIAL_GET_FROM_READID_ATTEMPT, {
+    data: {
+      biometricSessionId,
path: backend-api/src/functions/asyncIssueBiometricCredential/getBiometricSession/getBiometricSession.ts
user: login: tmulberry-gds
body: I have removed this from the log files
created_at: 2025-04-23T14:24:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -386,30 +609,43 @@ describe("Async Issue Biometric Credential", () => {
         expect(mockGetSecretsSuccess).not.toHaveBeenCalled();
       });
 
-      it("Logs COMPLETED with sessionId", async () => {
+      it("Logs COMPLETED", async () => {
         expect(consoleInfoSpy).toHaveBeenCalledWithLogFields({
           messageCode: "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_COMPLETED",
-          sessionId: mockSessionId,
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.test.ts
user: login: tmulberry-gds
body: Weird, it didnt push the changes
created_at: 2025-04-24T08:12:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -386,30 +609,43 @@ describe("Async Issue Biometric Credential", () => {
         expect(mockGetSecretsSuccess).not.toHaveBeenCalled();
       });
 
-      it("Logs COMPLETED with sessionId", async () => {
+      it("Logs COMPLETED", async () => {
         expect(consoleInfoSpy).toHaveBeenCalledWithLogFields({
           messageCode: "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_COMPLETED",
-          sessionId: mockSessionId,
         });
       });
     });
 
-    describe("Given sessionState is ASYNC_BIOMETRIC_SESSION_FINISHED", () => {
+    it("Passes correct arguments to get biometric session", async () => {
+      await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
+      expect(mockGetBiometricSessionSuccess).toHaveBeenCalledWith(
+        "mockReadIdBaseUrl",
+        mockSessionId,
+        "mockViewerKey",
+      );
+    });
+
+    it("Logs COMPLETED", async () => {
+      await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
+      expect(consoleInfoSpy).toHaveBeenCalledWithLogFields({
+        messageCode: "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_COMPLETED",
+      });
+    });
+
+    describe("Given sessionState is ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_FINISHED", () => {
       beforeEach(async () => {
         await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
       });
-
-      it("Passes correct arguments to get secrets", () => {
+      it("Passes correct arguments to get secrets", async () => {
         expect(mockGetSecretsSuccess).toHaveBeenCalledWith({
           secretNames: ["mockBiometricViewerAccessKey"],
           cacheDurationInSeconds: 900,
         });
       });
 
-      it("Logs COMPLETED with sessionId", async () => {
+      it("Logs COMPLETED", async () => {
         expect(consoleInfoSpy).toHaveBeenCalledWithLogFields({
           messageCode: "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_COMPLETED",
-          sessionId: mockSessionId,
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.test.ts
user: login: tmulberry-gds
body: Same as above, didn tpush the changes for some reason
created_at: 2025-04-24T08:12:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -386,30 +609,43 @@ describe("Async Issue Biometric Credential", () => {
         expect(mockGetSecretsSuccess).not.toHaveBeenCalled();
       });
 
-      it("Logs COMPLETED with sessionId", async () => {
+      it("Logs COMPLETED", async () => {
         expect(consoleInfoSpy).toHaveBeenCalledWithLogFields({
           messageCode: "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_COMPLETED",
-          sessionId: mockSessionId,
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.test.ts
user: login: chris-cooksley-gds
body: This one is still missing
created_at: 2025-04-24T08:16:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -391,181 +390,201 @@ describe("Async Issue Biometric Credential", () => {
         it("Does not log COMPLETED", () => {
           expect(consoleInfoSpy).not.toHaveBeenCalledWithLogFields({
             messageCode: "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_COMPLETED",
-            sessionId: mockSessionId,
           });
         });
       });
     });
   });
 
-  describe("When there is an error getting secrets", () => {
+  describe("When biometric session state is RESULT_SENT", () => {
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.test.ts
user: login: chris-cooksley-gds
body: ```suggestion
  describe("When session state is ASYNC_RESULT_SENT", () => {
```

This is the user session, not the biometric session.

As a reminder:
* the user session is stored in dynamo, managed by us, has a bunch of useful fields in (e.g. subject ID, client ID, redirect URI), and follows a state machine (e.g. ASYNC_AUTH_SESSION_CREATED, ASYNC_BIOMETRIC_TOKEN_ISSUED, etc)
* the biometric session is managed by ReadID. The mobile app started the biometric session with ReadID and we're retrieving it in this ticket. It will include results of the checks that ReadID has done.
created_at: 2025-04-24T08:47:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -391,181 +390,201 @@ describe("Async Issue Biometric Credential", () => {
         it("Does not log COMPLETED", () => {
           expect(consoleInfoSpy).not.toHaveBeenCalledWithLogFields({
             messageCode: "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_COMPLETED",
-            sessionId: mockSessionId,
           });
         });
       });
     });
   });
 
-  describe("When there is an error getting secrets", () => {
+  describe("When biometric session state is RESULT_SENT", () => {
     beforeEach(async () => {
-      dependencies.getSecrets = jest.fn().mockResolvedValue(emptyFailure());
-      try {
-        await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
-      } catch (error: unknown) {
-        lambdaError = error;
-      }
+      dependencies.getSessionRegistry = () => ({
+        ...mockInertSessionRegistry,
+        getSession: jest
+          .fn()
+          .mockResolvedValue(successResult(validResultSentAttributes)),
+      });
+      await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
     });
 
-    it("Throws RetainMessageOnQueue", async () => {
-      expect(lambdaError).toStrictEqual(
-        new RetainMessageOnQueue("Failed to retrieve biometric viewer key"),
-      );
+    it("Does not make a call to get secrets", () => {
+      expect(mockGetSecretsSuccess).not.toHaveBeenCalled();
+    });
+
+    it("Logs COMPLETED with sessionId", async () => {
+      expect(consoleInfoSpy).toHaveBeenCalledWithLogFields({
+        messageCode: "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_COMPLETED",
+        sessionId: mockSessionId,
+      });
     });
   });
 
-  describe("When biometric session is not ready", () => {
+  describe("When biometric session state is ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_FINISHED", () => {
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.test.ts
user: login: chris-cooksley-gds
body: ```suggestion
  describe("When session state is ASYNC_BIOMETRIC_SESSION_FINISHED", () => {
```
created_at: 2025-04-24T08:48:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -386,30 +609,43 @@ describe("Async Issue Biometric Credential", () => {
         expect(mockGetSecretsSuccess).not.toHaveBeenCalled();
       });
 
-      it("Logs COMPLETED with sessionId", async () => {
+      it("Logs COMPLETED", async () => {
         expect(consoleInfoSpy).toHaveBeenCalledWithLogFields({
           messageCode: "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_COMPLETED",
-          sessionId: mockSessionId,
         });
       });
     });
 
-    describe("Given sessionState is ASYNC_BIOMETRIC_SESSION_FINISHED", () => {
+    it("Passes correct arguments to get biometric session", async () => {
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.test.ts
user: login: tmulberry-gds
body: I've split them up into their corrosponding pths
created_at: 2025-04-24T08:52:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -391,181 +390,201 @@ describe("Async Issue Biometric Credential", () => {
         it("Does not log COMPLETED", () => {
           expect(consoleInfoSpy).not.toHaveBeenCalledWithLogFields({
             messageCode: "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_COMPLETED",
-            sessionId: mockSessionId,
           });
         });
       });
     });
   });
 
-  describe("When there is an error getting secrets", () => {
+  describe("When biometric session state is RESULT_SENT", () => {
     beforeEach(async () => {
-      dependencies.getSecrets = jest.fn().mockResolvedValue(emptyFailure());
-      try {
-        await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
-      } catch (error: unknown) {
-        lambdaError = error;
-      }
+      dependencies.getSessionRegistry = () => ({
+        ...mockInertSessionRegistry,
+        getSession: jest
+          .fn()
+          .mockResolvedValue(successResult(validResultSentAttributes)),
+      });
+      await lambdaHandlerConstructor(dependencies, validSqsEvent, context);
     });
 
-    it("Throws RetainMessageOnQueue", async () => {
-      expect(lambdaError).toStrictEqual(
-        new RetainMessageOnQueue("Failed to retrieve biometric viewer key"),
-      );
+    it("Does not make a call to get secrets", () => {
+      expect(mockGetSecretsSuccess).not.toHaveBeenCalled();
+    });
+
+    it("Logs COMPLETED with sessionId", async () => {
+      expect(consoleInfoSpy).toHaveBeenCalledWithLogFields({
+        messageCode: "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_COMPLETED",
+        sessionId: mockSessionId,
+      });
     });
   });
 
-  describe("When biometric session is not ready", () => {
+  describe("When biometric session state is ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_FINISHED", () => {
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.test.ts
user: login: tmulberry-gds
body: commited this
created_at: 2025-04-24T08:53:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -391,181 +390,201 @@ describe("Async Issue Biometric Credential", () => {
         it("Does not log COMPLETED", () => {
           expect(consoleInfoSpy).not.toHaveBeenCalledWithLogFields({
             messageCode: "MOBILE_ASYNC_ISSUE_BIOMETRIC_CREDENTIAL_COMPLETED",
-            sessionId: mockSessionId,
           });
         });
       });
     });
   });
 
-  describe("When there is an error getting secrets", () => {
+  describe("When biometric session state is RESULT_SENT", () => {
path: backend-api/src/functions/asyncIssueBiometricCredential/asyncIssueBiometricCredentialHandler.test.ts
user: login: tmulberry-gds
body: comitted this
created_at: 2025-04-24T08:53:59Z
author_association: CONTRIBUTOR

PR comments:
user: login: sonarqubecloud[bot]
created_at: 2025-04-24T08:56:34Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=mobile-id-check-async&pullRequest=586) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=mobile-id-check-async&pullRequest=586&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=mobile-id-check-async&pullRequest=586&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=mobile-id-check-async&pullRequest=586&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [97.5% Coverage on New Code](https://sonarcloud.io/component_measures?id=mobile-id-check-async&pullRequest=586&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=mobile-id-check-async&pullRequest=586&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=mobile-id-check-async&pullRequest=586)


author_association: NONE