Repository: segmentio/action-destinations
Pull request #2853: [STRAT-5634] | Multistatus Support on Google Enhanced Conversions.
Author: login: Innovative-GauravKochar
Labels: name: Ready for Release
name: mode:cloud
name: actions:core
name: team:segment
Created: 2025-04-07T10:35:10Z | Merged: 2025-04-29T11:26:11Z

<!-- Hello and thank you for contributing to Segment action-destinations! -->

<!-- Before opening your pull request, make sure you have added and ran unit
     tests and tested your change locally. Refer to our testing
     documentation for more information: https://github.com/segmentio/action-destinations/blob/main/docs/testing.md -->

<!-- If you have questions or issues please open a new issue or create a new discussion
     post in Github. -->

This Pull request is to add multiStatus support on userList action of Google Enhanced Conversions.
JIRA TICKET:-https://segment.atlassian.net/jira/software/c/projects/STRATCONN/boards/310?assignee=63617339fc0cc7a600b03c6b&selectedIssue=STRATCONN-5634

[Local Testing ](https://docs.google.com/document/d/10FYOD__Oy1UReFrT14qLRLaFuaiMCTFxT7DMu5kvovY/edit?tab=t.0#heading=h.qt2yy6xbeark)

[Stage Testing completed successfully](https://docs.google.com/document/d/10FYOD__Oy1UReFrT14qLRLaFuaiMCTFxT7DMu5kvovY/edit?tab=t.9kxz6mpp9yxv) via Port Forwarding as users are not getting sync while creating audience in stage.

Stage Testing via [Bulk Batching ](https://docs.google.com/document/d/10FYOD__Oy1UReFrT14qLRLaFuaiMCTFxT7DMu5kvovY/edit?tab=t.g0gmbyeeqkrj)

## Testing

_Include any additional information about the testing you have completed to
ensure your changes behave as expected. For a speedy review, please check
any of the tasks you completed below during your testing._

- [x] Added [unit tests](https://github.com/segmentio/action-destinations/blob/main/docs/testing.md#local-end-to-end-testing) for new functionality
- [x] Tested end-to-end using the [local server](https://github.com/segmentio/action-destinations/blob/main/docs/testing.md#local-end-to-end-testing)
- [ ] [If destination is already live] Tested for backward compatibility of destination. **Note:** New required fields are a breaking change.
- [x] [Segmenters] Tested in the staging environment
- [ ] [Segmenters] [If applicable for this change] Tested for regression with Hadron. 



Diff:
diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts
index f2e5d82b038..c24f465f38a 100644
--- a/packages/core/src/errors.ts
+++ b/packages/core/src/errors.ts
@@ -182,7 +182,8 @@ export enum HttpErrorCodes {
   BANDWIDTH_LIMIT_EXCEEDED = 'BANDWIDTH_LIMIT_EXCEEDED',
   NOT_EXTENDED = 'NOT_EXTENDED',
   NETWORK_AUTHENTICATION_REQUIRED = 'NETWORK_AUTHENTICATION_REQUIRED',
-  SITE_IS_OVERLOADED = 'SITE_IS_OVERLOADED'
+  SITE_IS_OVERLOADED = 'SITE_IS_OVERLOADED',
+  CANCELLED = 'CANCELLED'
 }
 
 export enum CustomErrorCodes {
@@ -258,6 +259,7 @@ const HTTP_ERROR_CODE_MAP: Record<number, HttpErrorCodes> = {
   429: HttpErrorCodes.TOO_MANY_REQUESTS,
   431: HttpErrorCodes.REQUEST_HEADER_FIELDS_TOO_LARGE,
   451: HttpErrorCodes.UNAVAILABLE_FOR_LEGAL_REASONS,
+  499: HttpErrorCodes.CANCELLED,
   500: HttpErrorCodes.INTERNAL_SERVER_ERROR,
   501: HttpErrorCodes.NOT_IMPLEMENTED,
   502: HttpErrorCodes.BAD_GATEWAY,
diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/__tests__/userList.test.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/__tests__/userList.test.ts
index 3ce041f0e4f..e2590f57cd3 100644
--- a/packages/destination-actions/src/destinations/google-enhanced-conversions/__tests__/userList.test.ts
+++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/__tests__/userList.test.ts
@@ -1,5 +1,5 @@
 import nock from 'nock'
-import { createTestEvent, createTestIntegration, HTTPError, RetryableError } from '@segment/actions-core'
+import { createTestEvent, createTestIntegration } from '@segment/actions-core'
 import GoogleEnhancedConversions from '../index'
 import { API_VERSION } from '../functions'
 import { SegmentEvent } from '@segment/actions-core'
@@ -7,7 +7,25 @@ import { SegmentEvent } from '@segment/actions-core'
 const testDestination = createTestIntegration(GoogleEnhancedConversions)
 const timestamp = new Date('Thu Jun 10 2021 11:08:04 GMT-0700 (Pacific Daylight Time)').toISOString()
 const customerId = '1234'
-
+const mapping = {
+  phone: { '@path': '$.properties.phone' },
+  email: { '@path': '$.properties.email' },
+  firstName: { '@path': '$.properties.firstName' },
+  lastName: { '@path': '$.properties.lastName' },
+  event_name: {
+    '@path': '$.event'
+  },
+  ad_user_data_consent_state: 'DENIED',
+  ad_personalization_consent_state: 'UNSPECIFIED',
+  external_audience_id: '1234',
+  retlOnMappingSave: {
+    outputs: {
+      id: '1234',
+      name: 'Test List',
+      external_id_type: 'CONTACT_INFO'
+    }
+  }
+}
 describe('GoogleEnhancedConversions', () => {
   describe('userList', () => {
     it('sends an event with default mappings - event = Audience Entered', async () => {
@@ -455,7 +473,7 @@ describe('GoogleEnhancedConversions', () => {
       )
     })
 
-    it('rethrows concurrent_modification error from addOperations API as retryable error', async () => {
+    it('should successfully handle error other than CONCURRENT_MODIFICATION from createOfflineUserDataJobs API', async () => {
       const events: SegmentEvent[] = [
         createTestEvent({
           timestamp,
@@ -482,7 +500,7 @@ describe('GoogleEnhancedConversions', () => {
           event: 'Audience Entered',
           properties: {
             gclid: '54321',
-            email: 'test@gmail.com',
+            email: 'test+1@gmail.com',
             orderId: '1234',
             phone: '1234567890',
             firstName: 'Jane',
@@ -500,10 +518,6 @@ describe('GoogleEnhancedConversions', () => {
       ]
 
       nock(`https://googleads.googleapis.com/${API_VERSION}/customers/${customerId}/offlineUserDataJobs:create`)
-        .post(/.*/)
-        .reply(200, { data: 'offlineDataJob' })
-
-      nock(`https://googleads.googleapis.com/${API_VERSION}/offlineDataJob:addOperations`)
         .post(/.*/)
         .reply(400, {
           error: {
@@ -514,9 +528,10 @@ describe('GoogleEnhancedConversions', () => {
                 errors: [
                   {
                     errorCode: {
-                      databaseError: 'CONCURRENT_MODIFICATION'
+                      someotherError: 'DATA_CONSTRAINT_VIOLATION'
                     },
-                    message: 'Multiple requests were attempting to modify the same resource at once. Retry the request.'
+                    message:
+                      'The request conflicted with existing data. This error will usually be replaced with a more specific error if the request is retried.'
                   }
                 ],
                 requestId: 'OZ5_72C-3qFN9a87mjE7_w'
@@ -527,34 +542,46 @@ describe('GoogleEnhancedConversions', () => {
           }
         })
 
-      const responses = testDestination.testBatchAction('userList', {
+      const responses = await testDestination.executeBatch('userList', {
         events,
-        mapping: {
-          ad_user_data_consent_state: 'GRANTED',
-          ad_personalization_consent_state: 'GRANTED',
-          external_audience_id: '1234',
-          retlOnMappingSave: {
-            outputs: {
-              id: '1234',
-              name: 'Test List',
-              external_id_type: 'CONTACT_INFO'
-            }
-          }
-        },
-        useDefaultMappings: true,
+        mapping,
         settings: {
           customerId
         }
       })
 
-      await expect(responses).rejects.toThrowError(RetryableError)
-    })
+      expect(responses[0]).toMatchObject({
+        status: 400,
+        errortype: 'BAD_REQUEST',
+        errormessage: 'Request contains an invalid argument.',
+        errorreporter: 'DESTINATION',
+        sent: {
+          job: {
+            type: 'CUSTOMER_MATCH_USER_LIST',
+            customerMatchUserListMetadata: {
+              userList: 'customers/1234/userLists/1234',
+              consent: {
+                adUserData: 'DENIED',
+                adPersonalization: 'UNSPECIFIED'
+              }
+            }
+          }
+        },
+        body: new Error('Bad Request')
+      })
 
-    it('bubbles up errors other than concurrent_modification from addOperations API', async () => {
+      expect(responses[1]).toMatchObject({
+        status: 400,
+        errortype: 'BAD_REQUEST',
+        errormessage: 'Request contains an invalid argument.',
+        errorreporter: 'DESTINATION'
+      })
+    })
+    it('Rethrows CONCURRENT_MODIFICATION error from createOfflineUserDataJobs API as retryable error', async () => {
       const events: SegmentEvent[] = [
         createTestEvent({
           timestamp,
-          event: 'Audience Entered',
+          event: 'Audience Exited',
           properties: {
             gclid: '54321',
             email: 'test@gmail.com',
@@ -574,7 +601,7 @@ describe('GoogleEnhancedConversions', () => {
         }),
         createTestEvent({
           timestamp,
-          event: 'Audience Entered',
+          event: 'Audience Exited',
           properties: {
             gclid: '54321',
             email: 'test@gmail.com',
@@ -595,10 +622,6 @@ describe('GoogleEnhancedConversions', () => {
       ]
 
       nock(`https://googleads.googleapis.com/${API_VERSION}/customers/${customerId}/offlineUserDataJobs:create`)
-        .post(/.*/)
-        .reply(200, { data: 'offlineDataJob' })
-
-      nock(`https://googleads.googleapis.com/${API_VERSION}/offlineDataJob:addOperations`)
         .post(/.*/)
         .reply(400, {
           error: {
@@ -609,10 +632,9 @@ describe('GoogleEnhancedConversions', () => {
                 errors: [
                   {
                     errorCode: {
-                      databaseError: 'DATA_CONSTRAINT_VIOLATION'
+                      databaseError: 'CONCURRENT_MODIFICATION'
                     },
-                    message:
-                      'The request conflicted with existing data. This error will usually be replaced with a more specific error if the request is retried.'
+                    message: 'Multiple requests were attempting to modify the same resource at once. Retry the request.'
                   }
                 ],
                 requestId: 'OZ5_72C-3qFN9a87mjE7_w'
@@ -623,34 +645,63 @@ describe('GoogleEnhancedConversions', () => {
           }
         })
 
-      const responses = testDestination.testBatchAction('userList', {
+      // nock(`https://googleads.googleapis.com/${API_VERSION}/offlineDataJob:addOperations`).post(/.*/).reply(200)
+
+      const responses = await testDestination.executeBatch('userList', {
         events,
-        mapping: {
-          ad_user_data_consent_state: 'GRANTED',
-          ad_personalization_consent_state: 'GRANTED',
-          external_audience_id: '1234',
-          retlOnMappingSave: {
-            outputs: {
-              id: '1234',
-              name: 'Test List',
-              external_id_type: 'CONTACT_INFO'
-            }
-          }
-        },
-        useDefaultMappings: true,
+        mapping,
         settings: {
           customerId
         }
       })
+      expect(responses[0]).toMatchObject({
+        status: 429,
+        errortype: 'RETRYABLE_BATCH_FAILURE',
+        errormessage:
+          "This event wasn't delivered because of CONCURRENT_MODIFICATION error. Multiple requests were attempting to modify the same resource at once. Retry the request.",
+        sent: {
+          job: {
+            type: 'CUSTOMER_MATCH_USER_LIST',
+            customerMatchUserListMetadata: {
+              userList: `customers/1234/userLists/1234`,
+              consent: {
+                adUserData: 'DENIED',
+                adPersonalization: 'UNSPECIFIED'
+              }
+            }
+          }
+        },
+        body: new Error('Bad Request'),
+        errorreporter: 'DESTINATION'
+      })
 
-      await expect(responses).rejects.toThrowError(HTTPError)
+      expect(responses[1]).toMatchObject({
+        status: 429,
+        errortype: 'RETRYABLE_BATCH_FAILURE',
+        errormessage:
+          "This event wasn't delivered because of CONCURRENT_MODIFICATION error. Multiple requests were attempting to modify the same resource at once. Retry the request.",
+        sent: {
+          job: {
+            type: 'CUSTOMER_MATCH_USER_LIST',
+            customerMatchUserListMetadata: {
+              userList: `customers/1234/userLists/1234`,
+              consent: {
+                adUserData: 'DENIED',
+                adPersonalization: 'UNSPECIFIED'
+              }
+            }
+          }
+        },
+        body: new Error('Bad Request'),
+        errorreporter: 'DESTINATION'
+      })
     })
 
-    it('rethrows concurrent_modification error from create offlineUserDataJobs API as retryable error', async () => {
+    it('Rethrows CONCURRENT_MODIFICATION error from addOperation offlineUserDataJobs API as retryable error', async () => {
       const events: SegmentEvent[] = [
         createTestEvent({
           timestamp,
-          event: 'Audience Entered',
+          event: 'Audience Exited',
           properties: {
             gclid: '54321',
             email: 'test@gmail.com',
@@ -670,7 +721,7 @@ describe('GoogleEnhancedConversions', () => {
         }),
         createTestEvent({
           timestamp,
-          event: 'Audience Entered',
+          event: 'Audience Exited',
           properties: {
             gclid: '54321',
             email: 'test@gmail.com',
@@ -691,6 +742,10 @@ describe('GoogleEnhancedConversions', () => {
       ]
 
       nock(`https://googleads.googleapis.com/${API_VERSION}/customers/${customerId}/offlineUserDataJobs:create`)
+        .post(/.*/)
+        .reply(200, { resourceName: 'customers/1234/userList/1234' })
+
+      nock(`https://googleads.googleapis.com/${API_VERSION}/offlineDataJob:addOperations`)
         .post(/.*/)
         .reply(400, {
           error: {
@@ -714,37 +769,99 @@ describe('GoogleEnhancedConversions', () => {
           }
         })
 
-      const responses = testDestination.testBatchAction('userList', {
+      const responses = await testDestination.executeBatch('userList', {
         events,
-        mapping: {
-          ad_user_data_consent_state: 'GRANTED',
-          ad_personalization_consent_state: 'GRANTED',
-          external_audience_id: '1234',
-          retlOnMappingSave: {
-            outputs: {
-              id: '1234',
-              name: 'Test List',
-              external_id_type: 'CONTACT_INFO'
-            }
-          }
-        },
-        useDefaultMappings: true,
+        mapping,
         settings: {
           customerId
         }
       })
+      expect(responses[0]).toMatchObject({
+        status: 429,
+        errortype: 'RETRYABLE_BATCH_FAILURE',
+        errormessage:
+          "This event wasn't delivered because of CONCURRENT_MODIFICATION error. Multiple requests were attempting to modify the same resource at once. Retry the request.",
+        sent: {
+          enablePartialFailure: true,
+          operations: [
+            {
+              remove: {
+                userIdentifiers: [
+                  {
+                    hashedEmail: '87924606b4131a8aceeeae8868531fbb9712aaa07a5d3a756b26ce0f5d6ca674'
+                  },
+                  {
+                    hashedPhoneNumber: '422ce82c6fc1724ac878042f7d055653ab5e983d186e616826a72d4384b68af8'
+                  }
+                ]
+              }
+            },
+            {
+              remove: {
+                userIdentifiers: [
+                  {
+                    hashedEmail: '87924606b4131a8aceeeae8868531fbb9712aaa07a5d3a756b26ce0f5d6ca674'
+                  },
+                  {
+                    hashedPhoneNumber: '422ce82c6fc1724ac878042f7d055653ab5e983d186e616826a72d4384b68af8'
+                  }
+                ]
+              }
+            }
+          ]
+        },
+        body: new Error('Bad Request'),
+        errorreporter: 'DESTINATION'
+      })
 
-      await expect(responses).rejects.toThrowError(RetryableError)
+      expect(responses[1]).toMatchObject({
+        status: 429,
+        errortype: 'RETRYABLE_BATCH_FAILURE',
+        errormessage:
+          "This event wasn't delivered because of CONCURRENT_MODIFICATION error. Multiple requests were attempting to modify the same resource at once. Retry the request.",
+        sent: {
+          enablePartialFailure: true,
+          operations: [
+            {
+              remove: {
+                userIdentifiers: [
+                  {
+                    hashedEmail: '87924606b4131a8aceeeae8868531fbb9712aaa07a5d3a756b26ce0f5d6ca674'
+                  },
+                  {
+                    hashedPhoneNumber: '422ce82c6fc1724ac878042f7d055653ab5e983d186e616826a72d4384b68af8'
+                  }
+                ]
+              }
+            },
+            {
+              remove: {
+                userIdentifiers: [
+                  {
+                    hashedEmail: '87924606b4131a8aceeeae8868531fbb9712aaa07a5d3a756b26ce0f5d6ca674'
+                  },
+                  {
+                    hashedPhoneNumber: '422ce82c6fc1724ac878042f7d055653ab5e983d186e616826a72d4384b68af8'
+                  }
+                ]
+              }
+            }
+          ]
+        },
+        body: new Error('Bad Request'),
+        errorreporter: 'DESTINATION'
+      })
     })
 
-    it('bubbles up errors other than concurrent_modification from create offlineUserDataJobs API', async () => {
+    it('should successfully handle a Partial failure error from  addOperation offlineUserDataJobs API', async () => {
       const events: SegmentEvent[] = [
+        // Assume this Payload gets failed in Partial Failure
         createTestEvent({
           timestamp,
           event: 'Audience Entered',
           properties: {
             gclid: '54321',
-            email: 'test@gmail.com',
+            email: 'test+1@gmail.com',
             orderId: '1234',
             phone: '1234567890',
             firstName: 'Jane',
@@ -759,12 +876,66 @@ describe('GoogleEnhancedConversions', () => {
             }
           }
         }),
+        // Invalid Email
         createTestEvent({
           timestamp,
           event: 'Audience Entered',
           properties: {
             gclid: '54321',
-            email: 'test@gmail.com',
+            email: 'invalid_email',
+            orderId: '1234',
+            phone: '1234567890',
+            firstName: 'Jane',
+            lastName: 'Doe',
+            currency: 'USD',
+            value: '123'
+          }
+        }),
+        //Valid Payload
+        createTestEvent({
+          timestamp,
+          event: 'Audience Entered',
+          properties: {
+            gclid: '54321',
+            email: 'test+2@gmail.com',
+            orderId: '1234',
+            phone: '1234567890',
+            firstName: 'Jane',
+            lastName: 'Doe',
+            currency: 'USD',
+            value: '123',
+            address: {
+              street: '123 Street SW',
+              city: 'San Diego',
+              state: 'CA',
+              postalCode: '982004'
+            }
+          }
+        }),
+        // Missing email ,phone and addressInfo which is necessary for CONTACT_INFO
+        createTestEvent({
+          timestamp,
+          event: 'Audience Entered',
+          properties: {
+            gclid: '54321',
+            orderId: '1234',
+            currency: 'USD',
+            value: '123',
+            address: {
+              street: '123 Street SW',
+              city: 'San Diego',
+              state: 'CA',
+              postalCode: '982004'
+            }
+          }
+        }),
+        //Assume this Payload also gets failed in Partial Failure
+        createTestEvent({
+          timestamp,
+          event: 'Audience Entered',
+          properties: {
+            gclid: '54321',
+            email: 'test+3@gmail.com',
             orderId: '1234',
             phone: '1234567890',
             firstName: 'Jane',
@@ -783,53 +954,357 @@ describe('GoogleEnhancedConversions', () => {
 
       nock(`https://googleads.googleapis.com/${API_VERSION}/customers/${customerId}/offlineUserDataJobs:create`)
         .post(/.*/)
-        .reply(400, {
-          error: {
-            code: 400,
+        .reply(200, { resourceName: 'customers/1234/userLists/1234' })
+
+      nock(`https://googleads.googleapis.com/${API_VERSION}/offlineDataJob:addOperations`)
+        .post(/.*/)
+        .reply(200, {
+          partialFailureError: {
+            code: 3,
+            message: 'Mocking Partial Failure Error',
             details: [
               {
                 '@type': 'type.googleapis.com/google.ads.googleads.v17.errors.GoogleAdsFailure',
                 errors: [
                   {
                     errorCode: {
-                      someotherError: 'DATA_CONSTRAINT_VIOLATION'
+                      offlineUserDataJobError: 'INVALID_SHA256_FORMAT'
                     },
-                    message:
-                      'The request conflicted with existing data. This error will usually be replaced with a more specific error if the request is retried.'
+                    message: 'The SHA256 encoded value is malformed.',
+                    location: {
+                      fieldPathElements: [
+                        {
+                          fieldName: 'operations',
+                          index: 0
+                        },
+                        {
+                          fieldName: 'create'
+                        },
+                        {
+                          fieldName: 'user_identifiers',
+                          index: 0
+                        },
+                        {
+                          fieldName: 'hashed_email'
+                        }
+                      ]
+                    }
+                  },
+                  {
+                    errorCode: {
+                      offlineUserDataJobError: 'INVALID_SHA256_FORMAT'
+                    },
+                    message: 'The SHA256 encoded value is malformed.',
+                    location: {
+                      fieldPathElements: [
+                        {
+                          fieldName: 'operations',
+                          index: 2
+                        },
+                        {
+                          fieldName: 'create'
+                        },
+                        {
+                          fieldName: 'user_identifiers',
+                          index: 0
+                        },
+                        {
+                          fieldName: 'hashed_email'
+                        }
+                      ]
+                    }
                   }
-                ],
-                requestId: 'OZ5_72C-3qFN9a87mjE7_w'
+                ]
               }
-            ],
-            message: 'Request contains an invalid argument.',
-            status: 'INVALID_ARGUMENT'
+            ]
           }
         })
 
-      const responses = testDestination.testBatchAction('userList', {
+      nock(`https://googleads.googleapis.com/${API_VERSION}/offlineDataJob:run`).post(/.*/).reply(200, { done: true })
+
+      const responses = await testDestination.executeBatch('userList', {
         events,
-        mapping: {
-          ad_user_data_consent_state: 'GRANTED',
-          ad_personalization_consent_state: 'GRANTED',
-          external_audience_id: '1234',
-          retlOnMappingSave: {
-            outputs: {
-              id: '1234',
-              name: 'Test List',
-              external_id_type: 'CONTACT_INFO'
-            }
+        mapping,
+        settings: {
+          customerId
+        }
+      })
+
+      //Partial Failure(invalid payload) due to invalid Payload
+      expect(responses[0]).toMatchObject({
+        status: 400,
+        errortype: 'BAD_REQUEST',
+        errormessage: 'The SHA256 encoded value is malformed.',
+        sent: {
+          create: {
+            userIdentifiers: [
+              {
+                hashedEmail: 'ede2e3d4c737f00aaab14de19e6ce9e20248a35ea030882b72d5d22de1f867d6'
+              },
+              {
+                hashedPhoneNumber: '422ce82c6fc1724ac878042f7d055653ab5e983d186e616826a72d4384b68af8'
+              }
+            ]
           }
         },
-        useDefaultMappings: true,
+        body: {
+          errorCode: { offlineUserDataJobError: 'INVALID_SHA256_FORMAT' },
+          message: 'The SHA256 encoded value is malformed.',
+          location: {
+            fieldPathElements: [
+              {
+                fieldName: 'operations',
+                index: 0
+              },
+              {
+                fieldName: 'create'
+              },
+              {
+                fieldName: 'user_identifiers',
+                index: 0
+              },
+              {
+                fieldName: 'hashed_email'
+              }
+            ]
+          }
+        },
+        errorreporter: 'DESTINATION'
+      })
+      // Invalid Email
+      expect(responses[1]).toMatchObject({
+        status: 400,
+        errortype: 'PAYLOAD_VALIDATION_FAILED',
+        errormessage: "Email provided doesn't seem to be in a valid format.",
+        errorreporter: 'INTEGRATIONS'
+      })
+      //success
+      expect(responses[2]).toMatchObject({
+        status: 200,
+        sent: '/customers/1234/userLists/1234:run',
+        body: { done: true }
+      })
+      // Missing email ,phone and addressInfo which is necessary for CONTACT_INFO
+      expect(responses[3]).toMatchObject({
+        status: 400,
+        errortype: 'PAYLOAD_VALIDATION_FAILED',
+        errormessage: 'Missing or Invalid data for CONTACT_INFO.',
+        errorreporter: 'INTEGRATIONS'
+      })
+      //Partial Failure(invalid payload) due to invalid Payload
+      expect(responses[4]).toMatchObject({
+        status: 400,
+        errortype: 'BAD_REQUEST',
+        errormessage: 'The SHA256 encoded value is malformed.',
+        sent: {
+          create: {
+            userIdentifiers: [
+              {
+                hashedEmail: 'faca876f60e93cf76a50b2f7c980168ef03c49a6313038b6386057e98cc3cffc'
+              },
+              {
+                hashedPhoneNumber: '422ce82c6fc1724ac878042f7d055653ab5e983d186e616826a72d4384b68af8'
+              }
+            ]
+          }
+        },
+        body: {
+          errorCode: { offlineUserDataJobError: 'INVALID_SHA256_FORMAT' },
+          message: 'The SHA256 encoded value is malformed.',
+          location: {
+            fieldPathElements: [
+              {
+                fieldName: 'operations',
+                index: 2
+              },
+              {
+                fieldName: 'create'
+              },
+              {
+                fieldName: 'user_identifiers',
+                index: 0
+              },
+              {
+                fieldName: 'hashed_email'
+              }
+            ]
+          }
+        },
+        errorreporter: 'DESTINATION'
+      })
+    })
+
+    it('should successfully handle a batch of events where email is invalid or missing data for CONTACT_INFO', async () => {
+      nock(`https://googleads.googleapis.com/${API_VERSION}/customers/${customerId}/offlineUserDataJobs:create`)
+        .post(/.*/)
+        .reply(200, { resourceName: 'customers/1234/userLists/1234' })
+
+      nock(`https://googleads.googleapis.com/${API_VERSION}/offlineDataJob:addOperations`)
+        .post(/.*/)
+        .reply(200, { data: 'offlineDataJob' })
+
+      nock(`https://googleads.googleapis.com/${API_VERSION}/offlineDataJob:run`).post(/.*/).reply(200, { done: true })
+
+      const events: SegmentEvent[] = [
+        createTestEvent({
+          timestamp,
+          event: 'Audience Entered',
+          properties: {
+            gclid: '54321',
+            orderId: '1234',
+            currency: 'USD',
+            value: '123',
+            address: {
+              street: '123 Street SW',
+              city: 'San Diego',
+              state: 'CA',
+              postalCode: '982004'
+            }
+          }
+        }),
+        createTestEvent({
+          timestamp,
+          event: 'Audience Entered',
+          properties: {
+            gclid: '54321',
+            email: 'test@gmail.com',
+            orderId: '1234',
+            phone: '1234567890',
+            firstName: 'Jane',
+            lastName: 'Doe',
+            currency: 'USD',
+            value: '123',
+            address: {
+              street: '123 Street SW',
+              city: 'San Diego',
+              state: 'CA',
+              postalCode: '982004'
+            }
+          }
+        }),
+
+        createTestEvent({
+          timestamp,
+          event: 'Audience Entered',
+          properties: {
+            gclid: '54321',
+            email: 'invalid_email',
+            orderId: '1234',
+            phone: '1234567890',
+            firstName: 'Jane',
+            lastName: 'Doe',
+            currency: 'USD',
+            value: '123',
+            address: {
+              street: '123 Street SW',
+              city: 'San Diego',
+              state: 'CA',
+              postalCode: '982004'
+            }
+          }
+        })
+      ]
+
+      const responses = await testDestination.executeBatch('userList', {
+        events,
+        mapping,
+        settings: {
+          customerId
+        }
+      })
+
+      expect(responses[0]).toMatchObject({
+        status: 400,
+        errortype: 'PAYLOAD_VALIDATION_FAILED',
+        errormessage: 'Missing or Invalid data for CONTACT_INFO.',
+        errorreporter: 'INTEGRATIONS'
+      })
+
+      expect(responses[1]).toMatchObject({
+        status: 200,
+        sent: '/customers/1234/userLists/1234:run',
+        body: {
+          done: true
+        }
+      })
+
+      expect(responses[2]).toMatchObject({
+        status: 400,
+        errortype: 'PAYLOAD_VALIDATION_FAILED',
+        errormessage: "Email provided doesn't seem to be in a valid format.",
+        errorreporter: 'INTEGRATIONS'
+      })
+    })
+
+    it('Could not deteremine operation type due to invalid event name or syncMode', async () => {
+      nock(`https://googleads.googleapis.com/${API_VERSION}/customers/${customerId}/offlineUserDataJobs:create`)
+        .post(/.*/)
+        .reply(200, { resourceName: 'customers/1234/userLists/1234' })
+
+      const events: SegmentEvent[] = [
+        createTestEvent({
+          timestamp,
+          event: 'Invalid Event',
+          properties: {
+            email: 'test@gmail.com',
+            gclid: '54321',
+            orderId: '1234',
+            currency: 'USD',
+            value: '123',
+            address: {
+              street: '123 Street SW',
+              city: 'San Diego',
+              state: 'CA',
+              postalCode: '982004'
+            }
+          }
+        }),
+        createTestEvent({
+          timestamp,
+          event: 'Invalid Event Name',
+          properties: {
+            gclid: '54321',
+            email: 'test+1@gmail.com',
+            orderId: '1234',
+            phone: '1234567890',
+            firstName: 'Jane',
+            lastName: 'Doe',
+            currency: 'USD',
+            value: '123',
+            address: {
+              street: '123 Street SW',
+              city: 'San Diego',
+              state: 'CA',
+              postalCode: '982004'
+            }
+          }
+        })
+      ]
+
+      const responses = await testDestination.executeBatch('userList', {
+        events,
+        mapping,
         settings: {
           customerId
         }
       })
 
-      await expect(responses).rejects.toThrowError(HTTPError)
+      expect(responses[0]).toMatchObject({
+        status: 400,
+        errortype: 'PAYLOAD_VALIDATION_FAILED',
+        errormessage: 'Could not determine Operation Type.',
+        errorreporter: 'INTEGRATIONS'
+      })
+
+      expect(responses[1]).toMatchObject({
+        status: 400,
+        errortype: 'PAYLOAD_VALIDATION_FAILED',
+        errormessage: 'Could not determine Operation Type.',
+        errorreporter: 'INTEGRATIONS'
+      })
     })
 
-    it('rethrows concurrent_modification error from run offlineUserDataJobs API as retryable error', async () => {
+    it('Should successfully handle error other than CONCURRENT_MODIFICATION from run offlineUserDataJobs API', async () => {
       const events: SegmentEvent[] = [
         createTestEvent({
           timestamp,
@@ -875,7 +1350,7 @@ describe('GoogleEnhancedConversions', () => {
 
       nock(`https://googleads.googleapis.com/${API_VERSION}/customers/${customerId}/offlineUserDataJobs:create`)
         .post(/.*/)
-        .reply(200, { data: 'offlineDataJob' })
+        .reply(200, { resourceName: 'customers/1234/userLists/1234' })
 
       nock(`https://googleads.googleapis.com/${API_VERSION}/offlineDataJob:addOperations`)
         .post(/.*/)
@@ -892,9 +1367,10 @@ describe('GoogleEnhancedConversions', () => {
                 errors: [
                   {
                     errorCode: {
-                      databaseError: 'CONCURRENT_MODIFICATION'
+                      someotherError: 'DATA_CONSTRAINT_VIOLATION'
                     },
-                    message: 'Multiple requests were attempting to modify the same resource at once. Retry the request.'
+                    message:
+                      'The request conflicted with existing data. This error will usually be replaced with a more specific error if the request is retried.'
                   }
                 ],
                 requestId: 'OZ5_72C-3qFN9a87mjE7_w'
@@ -905,30 +1381,33 @@ describe('GoogleEnhancedConversions', () => {
           }
         })
 
-      const responses = testDestination.testBatchAction('userList', {
+      const responses = await testDestination.executeBatch('userList', {
         events,
-        mapping: {
-          ad_user_data_consent_state: 'GRANTED',
-          ad_personalization_consent_state: 'GRANTED',
-          external_audience_id: '1234',
-          retlOnMappingSave: {
-            outputs: {
-              id: '1234',
-              name: 'Test List',
-              external_id_type: 'CONTACT_INFO'
-            }
-          }
-        },
-        useDefaultMappings: true,
+        mapping,
         settings: {
           customerId
         }
       })
 
-      await expect(responses).rejects.toThrowError(RetryableError)
-    })
+      expect(responses[0]).toMatchObject({
+        status: 400,
+        errortype: 'BAD_REQUEST',
+        errormessage: 'Request contains an invalid argument.',
+        sent: '/customers/1234/userLists/1234:run',
+        body: new Error('Bad Request'),
+        errorreporter: 'DESTINATION'
+      })
 
-    it('bubbles up errors other than concurrent_modification from run offlineUserDataJobs API', async () => {
+      expect(responses[1]).toMatchObject({
+        status: 400,
+        errortype: 'BAD_REQUEST',
+        errormessage: 'Request contains an invalid argument.',
+        sent: '/customers/1234/userLists/1234:run',
+        body: new Error('Bad Request'),
+        errorreporter: 'DESTINATION'
+      })
+    })
+    it('Rethrows CONCURRENT_MODIFICATION error from run offlineUserDataJobs API as retryable error', async () => {
       const events: SegmentEvent[] = [
         createTestEvent({
           timestamp,
@@ -974,7 +1453,7 @@ describe('GoogleEnhancedConversions', () => {
 
       nock(`https://googleads.googleapis.com/${API_VERSION}/customers/${customerId}/offlineUserDataJobs:create`)
         .post(/.*/)
-        .reply(200, { data: 'offlineDataJob' })
+        .reply(200, { resourceName: 'customers/1234/userLists/1234' })
 
       nock(`https://googleads.googleapis.com/${API_VERSION}/offlineDataJob:addOperations`)
         .post(/.*/)
@@ -991,10 +1470,9 @@ describe('GoogleEnhancedConversions', () => {
                 errors: [
                   {
                     errorCode: {
-                      someotherError: 'DATA_CONSTRAINT_VIOLATION'
+                      databaseError: 'CONCURRENT_MODIFICATION'
                     },
-                    message:
-                      'The request conflicted with existing data. This error will usually be replaced with a more specific error if the request is retried.'
+                    message: 'Multiple requests were attempting to modify the same resource at once. Retry the request.'
                   }
                 ],
                 requestId: 'OZ5_72C-3qFN9a87mjE7_w'
@@ -1005,27 +1483,33 @@ describe('GoogleEnhancedConversions', () => {
           }
         })
 
-      const responses = testDestination.testBatchAction('userList', {
+      const responses = await testDestination.executeBatch('userList', {
         events,
-        mapping: {
-          ad_user_data_consent_state: 'GRANTED',
-          ad_personalization_consent_state: 'GRANTED',
-          external_audience_id: '1234',
-          retlOnMappingSave: {
-            outputs: {
-              id: '1234',
-              name: 'Test List',
-              external_id_type: 'CONTACT_INFO'
-            }
-          }
-        },
-        useDefaultMappings: true,
+        mapping,
         settings: {
           customerId
         }
       })
 
-      await expect(responses).rejects.toThrowError(HTTPError)
+      expect(responses[0]).toMatchObject({
+        status: 429,
+        errortype: 'RETRYABLE_BATCH_FAILURE',
+        errormessage:
+          "This event wasn't delivered because of CONCURRENT_MODIFICATION error. Multiple requests were attempting to modify the same resource at once. Retry the request.",
+        sent: '/customers/1234/userLists/1234:run',
+        body: new Error('Bad Request'),
+        errorreporter: 'DESTINATION'
+      })
+
+      expect(responses[1]).toMatchObject({
+        status: 429,
+        errortype: 'RETRYABLE_BATCH_FAILURE',
+        errormessage:
+          "This event wasn't delivered because of CONCURRENT_MODIFICATION error. Multiple requests were attempting to modify the same resource at once. Retry the request.",
+        sent: '/customers/1234/userLists/1234:run',
+        body: new Error('Bad Request'),
+        errorreporter: 'DESTINATION'
+      })
     })
   })
 })
diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/constants.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/constants.ts
index c6662d2a3dc..17533046797 100644
--- a/packages/destination-actions/src/destinations/google-enhanced-conversions/constants.ts
+++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/constants.ts
@@ -1,3 +1,57 @@
 // GEC's max batch size is 2000
 // https://developers.google.com/google-ads/api/docs/best-practices/quotas#conversion_upload_service
 export const GOOGLE_ENHANCED_CONVERSIONS_BATCH_SIZE = 1500
+
+export const STATUS_CODE_MAPPING = {
+  0: {
+    status: 200
+  },
+  1: {
+    status: 499
+  },
+  2: {
+    status: 500
+  },
+  3: {
+    status: 400
+  },
+  4: {
+    status: 504
+  },
+  5: {
+    status: 404
+  },
+  6: {
+    status: 409
+  },
+  7: {
+    status: 403
+  },
+  8: {
+    status: 429
+  },
+  9: {
+    status: 400
+  },
+  10: {
+    status: 409
+  },
+  11: {
+    status: 400
+  },
+  12: {
+    status: 501
+  },
+  13: {
+    status: 500
+  },
+  14: {
+    status: 503
+  },
+  15: {
+    status: 500
+  },
+  16: {
+    status: 401
+  }
+}
diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts
index e8ac4486370..2133c638fd4 100644
--- a/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts
+++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts
@@ -9,7 +9,9 @@ import {
   CreateAudienceInput,
   CreateGoogleAudienceResponse,
   UserListResponse,
-  UserList
+  UserList,
+  OfflineUserJobPayload,
+  AddOperationPayload
 } from './types'
 import {
   ModifiedResponse,
@@ -18,7 +20,10 @@ import {
   RetryableError,
   PayloadValidationError,
   DynamicFieldResponse,
-  Features
+  Features,
+  MultiStatusResponse,
+  JSONLikeObject,
+  ErrorCodes
 } from '@segment/actions-core'
 import { StatsContext } from '@segment/actions-core/destination-kit'
 import { fullFormats } from 'ajv-formats/dist/formats'
@@ -26,6 +31,7 @@ import { HTTPError } from '@segment/actions-core'
 import type { Payload as UserListPayload } from './userList/generated-types'
 import { RefreshTokenResponse } from '.'
 import { processHashing } from '../../lib/hashing-utils'
+import { STATUS_CODE_MAPPING } from './constants'
 export const API_VERSION = 'v17'
 export const CANARY_API_VERSION = 'v17'
 export const FLAGON_NAME = 'google-enhanced-canary-version'
@@ -228,7 +234,6 @@ export const commonEmailValidation = (email: string): string => {
     emailParts[0] = emailParts[0].replace('.', '')
     normalizedEmail = `${emailParts[0]}@${emailParts[1]}`
   }
-
   if (!(fullFormats.email as RegExp).test(normalizedEmail)) {
     throw new PayloadValidationError("Email provided doesn't seem to be in a valid format.")
   }
@@ -430,7 +435,6 @@ export function formatToE164(phoneNumber: string, countryCode: string): string {
   if (!formattedPhoneNumber.startsWith('+')) {
     formattedPhoneNumber = '+' + formattedPhoneNumber
   }
-
   return formattedPhoneNumber
 }
 
@@ -533,9 +537,8 @@ const extractUserIdentifiers = (
 
 const createOfflineUserJob = async (
   request: RequestClient,
-  payload: UserListPayload,
+  offlineUserJobPayload: OfflineUserJobPayload,
   settings: CreateAudienceInput['settings'],
-  hookListId?: string,
   features?: Features | undefined,
   statsContext?: StatsContext | undefined
 ) => {
@@ -543,43 +546,25 @@ const createOfflineUserJob = async (
     settings.customerId
   }/offlineUserDataJobs:create`
 
-  let external_audience_id = payload.external_audience_id
-  if (hookListId) {
-    external_audience_id = hookListId
-  }
-
-  const json = {
-    job: {
-      type: 'CUSTOMER_MATCH_USER_LIST',
-      customerMatchUserListMetadata: {
-        userList: `customers/${settings.customerId}/userLists/${external_audience_id}`,
-        consent: {
-          adUserData: payload.ad_user_data_consent_state,
-          adPersonalization: payload.ad_personalization_consent_state
-        }
-      }
-    }
-  }
-
   try {
     const response = await request(url, {
       method: 'post',
       headers: {
         'developer-token': `${process.env.ADWORDS_DEVELOPER_TOKEN}`
       },
-      json
+      json: offlineUserJobPayload
     })
-    return (response.data as any).resourceName
+    return { success: true, data: (response.data as any).resourceName }
   } catch (error) {
     statsContext?.statsClient?.incr('error.createJob', 1, statsContext?.tags)
-    handleGoogleAdsError(error)
+    return { success: false, error }
   }
 }
 
 const handleGoogleAdsError = (error: any) => {
   // Google throws 400 error for CONCURRENT_MODIFICATION error which is a retryable error
   // We rewrite this error to a 500 so that Centrifuge can retry the request
-  const errors = (error as GoogleAdsError).response?.data?.error?.details ?? []
+  const errors = (error as GoogleAdsError)?.response?.data?.error?.details ?? []
   for (const errorDetails of errors) {
     for (const errorItem of errorDetails.errors) {
       // https://developers.google.com/google-ads/api/reference/rpc/v17/DatabaseErrorEnum.DatabaseError
@@ -595,57 +580,59 @@ const handleGoogleAdsError = (error: any) => {
 
   throw error
 }
-
 const addOperations = async (
   request: RequestClient,
-  userIdentifiers: any,
+  addOperationPayload: AddOperationPayload,
   resourceName: string,
-  features?: Features | undefined,
-  statsContext?: StatsContext | undefined
+  features?: Features,
+  statsContext?: StatsContext
 ) => {
   const url = `https://googleads.googleapis.com/${getApiVersion(features, statsContext)}/${resourceName}:addOperations`
 
-  const json = {
-    operations: userIdentifiers,
-    enable_warnings: true
-  }
-
   try {
     const response = await request(url, {
       method: 'post',
-      headers: {
-        'developer-token': `${process.env.ADWORDS_DEVELOPER_TOKEN}`
-      },
-      json
+      headers: { 'developer-token': `${process.env.ADWORDS_DEVELOPER_TOKEN}` },
+      json: addOperationPayload
     })
-
-    return response.data
+    return { success: true, data: response.data }
   } catch (error) {
     statsContext?.statsClient?.incr('error.addOperations', 1, statsContext?.tags)
-    handleGoogleAdsError(error)
+    return { success: false, error }
   }
 }
 
-const runOfflineUserJob = async (
+const processOperations = async (
   request: RequestClient,
+  userIdentifiers: any,
   resourceName: string,
+  validPayloadIndicesBitmap: number[],
+  failedPayloadIndices: Set<number>,
+  multiStatusResponse: MultiStatusResponse,
   features?: Features | undefined,
   statsContext?: StatsContext | undefined
 ) => {
-  const url = `https://googleads.googleapis.com/${getApiVersion(features, statsContext)}/${resourceName}:run`
-
-  try {
-    const response = await request(url, {
-      method: 'post',
-      headers: {
-        'developer-token': `${process.env.ADWORDS_DEVELOPER_TOKEN}`
-      }
-    })
-
-    return response.data
-  } catch (error) {
-    statsContext?.statsClient?.incr('error.runJob', 1, statsContext?.tags)
-    handleGoogleAdsError(error)
+  const operationPayload = { operations: userIdentifiers, enablePartialFailure: true }
+  const { success, data, error } = await addOperations(request, operationPayload, resourceName, features, statsContext)
+  if (!success) {
+    handleGoogleAdsAPIErrorResponse(
+      error as GoogleAdsError,
+      validPayloadIndicesBitmap,
+      multiStatusResponse,
+      operationPayload,
+      failedPayloadIndices
+    )
+  }
+  const partialFailureError = (data as any)?.partialFailureError
+
+  if (partialFailureError) {
+    handlePartialFailureResponse(
+      partialFailureError,
+      validPayloadIndicesBitmap,
+      multiStatusResponse,
+      userIdentifiers,
+      failedPayloadIndices
+    )
   }
 }
 
@@ -660,28 +647,52 @@ export const handleUpdate = async (
   features?: Features | undefined,
   statsContext?: StatsContext
 ) => {
+  const externalAudienceId: string | undefined = hookListId || payloads[0]?.external_audience_id
+  if (!externalAudienceId) {
+    throw new PayloadValidationError('External Audience ID is required.')
+  }
   const id_type = hookListType ?? audienceSettings.external_id_type
   // Format the user data for Google Ads API
   const [adduserIdentifiers, removeUserIdentifiers] = extractUserIdentifiers(payloads, id_type, syncMode, features)
-
+  const offlineUserJobPayload = createOfflineUserJobPayload(externalAudienceId, payloads[0], settings.customerId)
   // Create an offline user data job
-  const resourceName = await createOfflineUserJob(request, payloads[0], settings, hookListId, features, statsContext)
-
+  const offlineUserJobResponse = await createOfflineUserJob(
+    request,
+    offlineUserJobPayload,
+    settings,
+    features,
+    statsContext
+  )
+  if (!offlineUserJobResponse.success) {
+    return handleGoogleAdsError(offlineUserJobResponse.error)
+  }
+  const resourceName = offlineUserJobResponse.data
   // Add operations to the offline user data job
   if (adduserIdentifiers.length > 0) {
-    await addOperations(request, adduserIdentifiers, resourceName, features, statsContext)
+    const addOperationPayload = { operations: adduserIdentifiers, enable_warnings: true }
+    const addResponse = await addOperations(request, addOperationPayload, resourceName, features, statsContext)
+    if (!addResponse.success) {
+      return handleGoogleAdsError(addResponse.error)
+    }
   }
 
   if (removeUserIdentifiers.length > 0) {
-    await addOperations(request, removeUserIdentifiers, resourceName, features, statsContext)
+    const removeOperationPayload = { operations: removeUserIdentifiers, enable_warnings: true }
+    const removeResponse = await addOperations(request, removeOperationPayload, resourceName, features, statsContext)
+    if (!removeResponse.success) {
+      return handleGoogleAdsError(removeResponse.error)
+    }
   }
 
   // Run the offline user data job
   const executedJob = await runOfflineUserJob(request, resourceName, features, statsContext)
+  if (!executedJob.success) {
+    return handleGoogleAdsError(executedJob.error)
+  }
 
   statsContext?.statsClient?.incr('success.offlineUpdateAudience', 1, statsContext?.tags)
 
-  return executedJob
+  return executedJob.data
 }
 
 /* Enforcing this here since Customer ID is required for the Google Ads API
@@ -692,3 +703,368 @@ export const verifyCustomerId = (customerId: string | undefined) => {
   }
   return customerId.replace(/-/g, '')
 }
+
+const handleGoogleAdsAPIErrorResponse = (
+  error: any,
+  validPayloadIndicesBitmap: number[],
+  multiStatusResponse: MultiStatusResponse,
+  payload: JSONLikeObject,
+  failedPayloadIndices?: Set<number>
+) => {
+  const errorData = error?.response?.data?.error
+  const parsedError = parseGoogleAdsError(errorData)
+  validPayloadIndicesBitmap.forEach((index) => {
+    multiStatusResponse.setErrorResponseAtIndex(index, {
+      ...parsedError,
+      body: error,
+      sent: payload
+    })
+    failedPayloadIndices?.add(index)
+  })
+}
+
+const parseGoogleAdsError = (error: any) => {
+  // Google throws 400 error for CONCURRENT_MODIFICATION error which is a retryable error
+  // We mark  this error to a 429 so that Centrifuge can retry the request.
+
+  const hasConcurrentModificationError = error?.details?.some((detail: any) => {
+    return detail?.errors?.some((e: any) => e?.errorCode?.databaseError === 'CONCURRENT_MODIFICATION')
+  })
+
+  return hasConcurrentModificationError
+    ? {
+        errormessage:
+          "This event wasn't delivered because of CONCURRENT_MODIFICATION error. Multiple requests were attempting to modify the same resource at once. Retry the request.",
+        errortype: 'RETRYABLE_BATCH_FAILURE' as keyof typeof ErrorCodes,
+        status: 429
+      }
+    : {
+        errormessage: error?.message,
+        status: error?.code
+      }
+}
+
+const updateMultiStatusResponseWithSuccess = (
+  executedJob: JSONLikeObject,
+  validPayloadIndicesBitmap: number[],
+  multiStatusResponse: MultiStatusResponse,
+  sentBody: JSONLikeObject | string,
+  failedPayloadIndices: Set<number>
+) => {
+  validPayloadIndicesBitmap.forEach((index) => {
+    if (!failedPayloadIndices.has(index)) {
+      multiStatusResponse.setSuccessResponseAtIndex(index, {
+        status: 200,
+        sent: sentBody,
+        body: executedJob.data as JSONLikeObject
+      })
+    }
+  })
+}
+
+export const handlePartialFailureResponse = (
+  partialFailureError: any,
+  validPayloadIndicesBitmap: number[],
+  multiStatusResponse: MultiStatusResponse,
+  userIdentifiers: any[],
+  failedPayloadIndices: Set<number>
+) => {
+  partialFailureError?.details?.forEach((detail: any) => {
+    detail.errors?.forEach((error: any) => {
+      const failedIndex = error.location?.fieldPathElements?.find(
+        (field: any) => field.fieldName === 'operations'
+      )?.index
+
+      if (failedIndex >= 0) {
+        const originalIndex = validPayloadIndicesBitmap[failedIndex]
+        multiStatusResponse.setErrorResponseAtIndex(originalIndex, {
+          status: STATUS_CODE_MAPPING?.[partialFailureError.code as keyof typeof STATUS_CODE_MAPPING]?.status ?? 500, // error code
+          errormessage: error.message,
+          sent: userIdentifiers?.[failedIndex],
+          body: error
+        })
+        failedPayloadIndices.add(originalIndex)
+      }
+    })
+  })
+}
+const runOfflineUserJob = async (
+  request: RequestClient,
+  resourceName: string,
+  features?: Features | undefined,
+  statsContext?: StatsContext | undefined
+) => {
+  const url = `https://googleads.googleapis.com/${getApiVersion(features, statsContext)}/${resourceName}:run`
+
+  try {
+    const response = await request(url, {
+      method: 'post',
+      headers: {
+        'developer-token': `${process.env.ADWORDS_DEVELOPER_TOKEN}`
+      }
+    })
+    return { success: true, data: response.data }
+  } catch (error) {
+    statsContext?.statsClient?.incr('error.runJob', 1, statsContext?.tags)
+    return { success: false, error }
+  }
+}
+
+export const createIdentifierExtractors = (features?: Features) => ({
+  MOBILE_ADVERTISING_ID: (payload: UserListPayload) => {
+    return payload.mobile_advertising_id?.trim() ? { mobileId: payload.mobile_advertising_id.trim() } : null
+  },
+
+  CRM_ID: (payload: UserListPayload) => {
+    return payload.crm_id?.trim() ? { thirdPartyUserId: payload.crm_id.trim() } : null
+  },
+
+  CONTACT_INFO: (payload: UserListPayload) => {
+    const identifiers = []
+
+    if (payload.email) {
+      identifiers.push({
+        hashedEmail: processHashing(
+          payload.email,
+          'sha256',
+          'hex',
+          features ?? {},
+          'actions-google-enhanced-conversions',
+          commonEmailValidation
+        )
+      })
+    }
+
+    if (payload.phone) {
+      identifiers.push({
+        hashedPhoneNumber: processHashing(
+          payload.phone,
+          'sha256',
+          'hex',
+          features ?? {},
+          'actions-google-enhanced-conversions',
+          (value) => formatPhone(value, payload.phone_country_code)
+        )
+      })
+    }
+
+    if (payload.first_name || payload.last_name || payload.country_code || payload.postal_code) {
+      identifiers.push({
+        addressInfo: {
+          hashedFirstName: payload.first_name
+            ? processHashing(payload.first_name, 'sha256', 'hex', features ?? {}, 'actions-google-enhanced-conversions')
+            : undefined,
+          hashedLastName: payload.last_name
+            ? processHashing(payload.last_name, 'sha256', 'hex', features ?? {}, 'actions-google-enhanced-conversions')
+            : undefined,
+          countryCode: payload.country_code || '',
+          postalCode: payload.postal_code || ''
+        }
+      })
+    }
+
+    return identifiers.length > 0 ? identifiers : null
+  }
+})
+
+const extractBatchUserIdentifiers = (
+  payloads: UserListPayload[],
+  idType: string,
+  multiStatusResponse: MultiStatusResponse,
+  syncMode?: string,
+  features?: Features
+) => {
+  const removeUserIdentifiers: any[] = []
+  const addUserIdentifiers: any[] = []
+  const validPayloadIndicesBitmap: number[] = []
+
+  //Identify the user identifiers based on the idType
+  const extractors = createIdentifierExtractors(features)
+
+  payloads.forEach((payload, index) => {
+    let userIdentifiers
+    try {
+      userIdentifiers = extractors[idType as keyof typeof extractors]?.(payload)
+    } catch (error) {
+      if (error instanceof PayloadValidationError) {
+        multiStatusResponse.setErrorResponseAtIndex(index, {
+          status: 400,
+          errortype: 'PAYLOAD_VALIDATION_FAILED',
+          errormessage: error.message
+        })
+      }
+      return
+    }
+    if (!userIdentifiers) {
+      multiStatusResponse?.setErrorResponseAtIndex(index, {
+        status: 400,
+        errortype: 'PAYLOAD_VALIDATION_FAILED',
+        errormessage: `Missing or Invalid data for ${idType}.`
+      })
+      return
+    }
+    const operationType = determineOperationType(payload, syncMode)
+    if (!operationType) {
+      multiStatusResponse.setErrorResponseAtIndex(index, {
+        status: 400,
+        errortype: 'PAYLOAD_VALIDATION_FAILED',
+        errormessage: 'Could not determine Operation Type.'
+      })
+      return
+    }
+
+    validPayloadIndicesBitmap.push(index)
+    if (operationType === 'add') {
+      addUserIdentifiers.push({ create: { userIdentifiers } })
+    } else {
+      removeUserIdentifiers.push({ remove: { userIdentifiers } })
+    }
+  })
+
+  return { addUserIdentifiers, removeUserIdentifiers, validPayloadIndicesBitmap }
+}
+
+// Helper function to determine operation type
+const determineOperationType = (payload: UserListPayload, syncMode?: string) => {
+  if (
+    payload.event_name === 'Audience Entered' ||
+    syncMode === 'add' ||
+    (syncMode === 'mirror' && payload.event_name === 'new')
+  ) {
+    return 'add'
+  } else if (
+    payload.event_name === 'Audience Exited' ||
+    syncMode === 'delete' ||
+    (syncMode === 'mirror' && payload.event_name === 'deleted')
+  ) {
+    return 'remove'
+  }
+
+  return null
+}
+
+const createOfflineUserJobPayload = (audienceId: string, payload: UserListPayload, customerId?: string) => ({
+  job: {
+    type: 'CUSTOMER_MATCH_USER_LIST',
+    customerMatchUserListMetadata: {
+      userList: `customers/${customerId}/userLists/${audienceId}`,
+      consent: {
+        adUserData: payload?.ad_user_data_consent_state,
+        adPersonalization: payload?.ad_personalization_consent_state
+      }
+    }
+  }
+})
+
+export const processBatchPayload = async (
+  request: RequestClient,
+  settings: CreateAudienceInput['settings'],
+  audienceSettings: CreateAudienceInput['audienceSettings'],
+  payloads: UserListPayload[],
+  hookListId: string,
+  hookListType: string,
+  syncMode?: string,
+  features?: Features | undefined,
+  statsContext?: StatsContext
+) => {
+  const externalAudienceId = hookListId || payloads[0]?.external_audience_id
+  if (!externalAudienceId) {
+    throw new PayloadValidationError('External Audience ID is required.')
+  }
+  const multiStatusResponse = new MultiStatusResponse()
+  const id_type = hookListType ?? audienceSettings.external_id_type
+  // Extract user identifiers and validPayloadIndicesBitmap from payloads
+  const { addUserIdentifiers, removeUserIdentifiers, validPayloadIndicesBitmap } = extractBatchUserIdentifiers(
+    payloads,
+    id_type,
+    multiStatusResponse,
+    syncMode,
+    features
+  )
+  // Create offline user data job payload
+  const offlineUserJobPayload = createOfflineUserJobPayload(externalAudienceId, payloads[0], settings.customerId)
+  // Step1 :- Create an Offline user data job
+  const {
+    success,
+    data: resourceName,
+    error
+  } = await createOfflineUserJob(request, offlineUserJobPayload, settings, features, statsContext)
+  if (!success) {
+    handleGoogleAdsAPIErrorResponse(
+      error as GoogleAdsError,
+      validPayloadIndicesBitmap,
+      multiStatusResponse,
+      offlineUserJobPayload
+    )
+    return multiStatusResponse
+  }
+  const failedPayloadIndices: Set<number> = new Set()
+  // Step 2:- Add operations to the Offline user data job
+  if (addUserIdentifiers.length > 0) {
+    await processOperations(
+      request,
+      addUserIdentifiers,
+      resourceName,
+      validPayloadIndicesBitmap,
+      failedPayloadIndices,
+      multiStatusResponse,
+      features,
+      statsContext
+    )
+  }
+
+  if (removeUserIdentifiers.length > 0) {
+    await processOperations(
+      request,
+      removeUserIdentifiers,
+      resourceName,
+      validPayloadIndicesBitmap,
+      failedPayloadIndices,
+      multiStatusResponse,
+      features,
+      statsContext
+    )
+  }
+  if (failedPayloadIndices.size === validPayloadIndicesBitmap.length) {
+    return multiStatusResponse // Return multi-status response if all operations failed
+  }
+
+  //Step 3:- Run the offline user data job
+  const executedJob: any = await runOfflineUserJob(request, resourceName, features, statsContext)
+  statsContext?.statsClient?.incr('success.offlineUpdateAudience', 1, statsContext?.tags)
+
+  const sentBody = `/${resourceName}:run`
+  if (!executedJob.success) {
+    handleJobExecutionError(executedJob, validPayloadIndicesBitmap, multiStatusResponse, sentBody, failedPayloadIndices)
+  } else {
+    updateMultiStatusResponseWithSuccess(
+      executedJob,
+      validPayloadIndicesBitmap,
+      multiStatusResponse,
+      sentBody,
+      failedPayloadIndices
+    )
+  }
+  return multiStatusResponse
+}
+
+export const handleJobExecutionError = (
+  executedJob: any,
+  validPayloadIndicesBitmap: number[],
+  multiStatusResponse: MultiStatusResponse,
+  sentBody: string,
+  failedPayloadIndices: Set<number>
+) => {
+  const executedJobError = executedJob.error?.response?.data?.error
+  const parsedError = parseGoogleAdsError(executedJobError)
+
+  validPayloadIndicesBitmap.forEach((index) => {
+    if (!failedPayloadIndices.has(index)) {
+      multiStatusResponse.setErrorResponseAtIndex(index, {
+        ...parsedError,
+        sent: sentBody,
+        body: executedJob.error
+      })
+    }
+  })
+}
diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/types.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/types.ts
index c2cd2af2439..c30940318e1 100644
--- a/packages/destination-actions/src/destinations/google-enhanced-conversions/types.ts
+++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/types.ts
@@ -179,3 +179,21 @@ export interface CreateGoogleAudienceResponse {
 export interface AudienceSettings {
   external_id_type: string
 }
+export interface OfflineUserJobPayload {
+  job: {
+    type: string
+    customerMatchUserListMetadata: {
+      userList: string
+      consent: {
+        adUserData?: string
+        adPersonalization?: string
+      }
+    }
+  }
+}
+
+export interface AddOperationPayload {
+  operations: any[]
+  enablePartialFailure?: boolean
+  enableWarnings?: boolean
+}
diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/index.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/index.ts
index b08712e8b4c..7999373daaf 100644
--- a/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/index.ts
+++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/index.ts
@@ -1,7 +1,14 @@
 import type { ActionDefinition } from '@segment/actions-core'
 import type { Settings } from '../generated-types'
 import type { Payload } from './generated-types'
-import { createGoogleAudience, getGoogleAudience, getListIds, handleUpdate, verifyCustomerId } from '../functions'
+import {
+  createGoogleAudience,
+  getGoogleAudience,
+  getListIds,
+  handleUpdate,
+  processBatchPayload,
+  verifyCustomerId
+} from '../functions'
 import { IntegrationError } from '@segment/actions-core'
 import { UserListResponse } from '../types'
 
@@ -319,8 +326,7 @@ const action: ActionDefinition<Settings, Payload> = {
     { settings, audienceSettings, payload, hookOutputs, statsContext, syncMode, features }
   ) => {
     settings.customerId = verifyCustomerId(settings.customerId)
-
-    return await handleUpdate(
+    return await processBatchPayload(
       request,
       settings,
       audienceSettings,


Reviews:
user: login: sayan-das-in
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T10:05:36Z
user: login: Innovative-GauravKochar
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T10:27:00Z
user: login: varadarajan-tw
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T13:37:39Z
user: login: Innovative-GauravKochar
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T15:04:00Z
user: login: sayan-das-in
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T07:41:13Z

Review comments:
diff_hunk: @@ -1,3 +1,57 @@
 // GEC's max batch size is 2000
 // https://developers.google.com/google-ads/api/docs/best-practices/quotas#conversion_upload_service
 export const GOOGLE_ENHANCED_CONVERSIONS_BATCH_SIZE = 1500
+
+export const STATUS_CODE_MAPPING = {
+  0: {
+    status: 200
+  },
+  1: {
+    status: 499
+  },
+  2: {
+    status: 500
+  },
+  3: {
+    status: 400
+  },
+  4: {
+    status: 504
+  },
+  5: {
+    status: 404
+  },
+  6: {
+    status: 409
+  },
+  7: {
+    status: 403
+  },
+  8: {
+    status: 429
+  },
+  9: {
+    status: 400
+  },
+  10: {
+    status: 409
+  },
+  11: {
+    status: 400
+  },
+  12: {
+    status: 501
+  },
+  13: {
+    status: 500
+  },
+  14: {
+    status: 503
+  },
+  15: {
+    status: 500
+  },
+  16: {
+    status: 401
+  }
+}
path: packages/destination-actions/src/destinations/google-enhanced-conversions/constants.ts
user: login: sayan-das-in
body: This object is defined with numeric keys, however Javascript doesn't support numeric keys and are eventually converted to strings ([ref](https://stackoverflow.com/questions/3633362/is-there-any-way-to-use-a-numeric-type-as-an-object-key)). Please make sure that you have handled this case.
created_at: 2025-04-08T10:05:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,3 +1,57 @@
 // GEC's max batch size is 2000
 // https://developers.google.com/google-ads/api/docs/best-practices/quotas#conversion_upload_service
 export const GOOGLE_ENHANCED_CONVERSIONS_BATCH_SIZE = 1500
+
+export const STATUS_CODE_MAPPING = {
+  0: {
+    status: 200
+  },
+  1: {
+    status: 499
+  },
+  2: {
+    status: 500
+  },
+  3: {
+    status: 400
+  },
+  4: {
+    status: 504
+  },
+  5: {
+    status: 404
+  },
+  6: {
+    status: 409
+  },
+  7: {
+    status: 403
+  },
+  8: {
+    status: 429
+  },
+  9: {
+    status: 400
+  },
+  10: {
+    status: 409
+  },
+  11: {
+    status: 400
+  },
+  12: {
+    status: 501
+  },
+  13: {
+    status: 500
+  },
+  14: {
+    status: 503
+  },
+  15: {
+    status: 500
+  },
+  16: {
+    status: 401
+  }
+}
path: packages/destination-actions/src/destinations/google-enhanced-conversions/constants.ts
user: login: Innovative-GauravKochar
body: Yes , it is working absolutely fine . Code 3 is mapping to status 400 bad Request 
<img width="1031" alt="Screenshot 2025-04-08 at 3 54 45 PM" src="https://github.com/user-attachments/assets/b6b324b3-323e-469b-8819-65abd8709d93" />
<img width="1038" alt="Screenshot 2025-04-08 at 3 54 52 PM" src="https://github.com/user-attachments/assets/af468d08-b6ce-4ba7-b4fa-c2fe074f0cfb" />


created_at: 2025-04-08T10:27:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -125,6 +132,7 @@ const action: ActionDefinition<Settings, Payload> = {
       label: 'External Audience ID',
       description: 'The ID of the List that users will be synced to.',
       type: 'string',
+      required: true,
path: packages/destination-actions/src/destinations/google-enhanced-conversions/userList/index.ts
user: login: varadarajan-tw
body: Why is this marked as a required field?
created_at: 2025-04-08T13:37:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -125,6 +132,7 @@ const action: ActionDefinition<Settings, Payload> = {
       label: 'External Audience ID',
       description: 'The ID of the List that users will be synced to.',
       type: 'string',
+      required: true,
path: packages/destination-actions/src/destinations/google-enhanced-conversions/userList/index.ts
user: login: Innovative-GauravKochar
body: Yes , You are right , it should not be marked as required true as we can also get the same ID from Hooks Output.
I did that while testing , skipped to remove this !
Will update this !
created_at: 2025-04-08T15:04:00Z
author_association: CONTRIBUTOR

PR comments:
user: login: codecov[bot]
created_at: 2025-04-07T10:40:53Z
body: ## [Codecov](https://app.codecov.io/gh/segmentio/action-destinations/pull/2853?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=segmentio) Report
Attention: Patch coverage is `80.26316%` with `30 lines` in your changes missing coverage. Please review.
> Project coverage is 78.48%. Comparing base [(`cf633f8`)](https://app.codecov.io/gh/segmentio/action-destinations/commit/cf633f818316f63a4772f92fd060606a8033fd00?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=segmentio) to head [(`828658c`)](https://app.codecov.io/gh/segmentio/action-destinations/commit/828658cab98bc3fd1f585823ddf69b8f99d653f6?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=segmentio).
> Report is 5 commits behind head on main.

| [Files with missing lines](https://app.codecov.io/gh/segmentio/action-destinations/pull/2853?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=segmentio) | Patch % | Lines |
|---|---|---|
| [...tinations/google-enhanced-conversions/functions.ts](https://app.codecov.io/gh/segmentio/action-destinations/pull/2853?src=pr&el=tree&filepath=packages%2Fdestination-actions%2Fsrc%2Fdestinations%2Fgoogle-enhanced-conversions%2Ffunctions.ts&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=segmentio#diff-cGFja2FnZXMvZGVzdGluYXRpb24tYWN0aW9ucy9zcmMvZGVzdGluYXRpb25zL2dvb2dsZS1lbmhhbmNlZC1jb252ZXJzaW9ucy9mdW5jdGlvbnMudHM=) | 79.59% | [14 Missing and 16 partials :warning: ](https://app.codecov.io/gh/segmentio/action-destinations/pull/2853?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=segmentio) |

<details><summary>Additional details and impacted files</summary>


```diff
@@             Coverage Diff             @@
##             main    #2853       +/-   ##
===========================================
+ Coverage   32.57%   78.48%   +45.91%     
===========================================
  Files          14     1100     +1086     
  Lines         703    22151    +21448     
  Branches      119     4560     +4441     
===========================================
+ Hits          229    17386    +17157     
- Misses        474     3423     +2949     
- Partials        0     1342     +1342     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/segmentio/action-destinations/pull/2853?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=segmentio).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=segmentio).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
author_association: NONE
user: login: varadarajan-tw
created_at: 2025-04-30T08:18:16Z
body: @Innovative-GauravKochar - This PR has been deployed
author_association: CONTRIBUTOR