Repository: amp-labs/connectors
Pull request #1547 (2025-04-04 13:55:51)

[CON-1441] feat(Brevo): enable Read and Write
 # Installation
<img width="942" alt="image" src="https://github.com/user-attachments/assets/2c71dc67-3165-48f7-96ab-b36be52e86f7" />






# Read
<img width="1491" alt="image" src="https://github.com/user-attachments/assets/2177861c-07dd-4adf-ad37-d13e641d89d6" />




# Destination webhook object response
<img width="1582" alt="image" src="https://github.com/user-attachments/assets/8488a050-7b5a-4c4e-a9cd-5e1b41ed5e06" />

<img width="1582" alt="image" src="https://github.com/user-attachments/assets/43ccb1fa-5f7d-4b69-88b9-ebc60696cf85" />





# Write
<img width="1711" alt="image" src="https://github.com/user-attachments/assets/a8790230-1a43-4018-8210-6e3903c3d189" />
<img width="1712" alt="image" src="https://github.com/user-attachments/assets/105fac29-5fda-4e09-b613-4de2fc3990d8" />
<img width="1695" alt="image" src="https://github.com/user-attachments/assets/7ce3e04f-aa1d-4318-9571-4c36fab99668" />





<img width="1479" alt="image" src="https://github.com/user-attachments/assets/dbbbf89f-7b85-4bd3-a00a-269a589d48be" />




<img width="1500" alt="image" src="https://github.com/user-attachments/assets/d25d7b1f-8fc8-4fd8-95ed-d131f2a11906" />
<img width="1498" alt="image" src="https://github.com/user-attachments/assets/0c01c80c-0c35-463d-be90-54257476ff46" />
<img width="1498" alt="image" src="https://github.com/user-attachments/assets/25ed0db9-daa2-4150-8ee9-071e650e8459" />






Patch:
diff --git a/common/parse.go b/common/parse.go
index 6b46248f..455dadf2 100644
--- a/common/parse.go
+++ b/common/parse.go
@@ -11,21 +11,53 @@ import (
 )
 
 type (
+	// NextPageFunc extracts the next page token/URL from the response body.
 	NextPageFunc func(*ajson.Node) (string, error)
-	RecordsFunc  func(*ajson.Node) ([]map[string]any, error)
+
+	// RecordsFunc extracts a list of records as map[string]any from the response body.
+	RecordsFunc func(*ajson.Node) ([]map[string]any, error)
+	// NodeRecordsFunc extracts a list of records as ajson.Node from the response body.
+	NodeRecordsFunc func(*ajson.Node) ([]*ajson.Node, error)
+
+	// RecordTransformer is a function that processes a JSON node and transforms it
+	// into a map representation, potentially applying structural modifications.
+	//
+	// Common use cases include:
+	// - Flattening nested objects (see FlattenNestedFields)
+	// - Filtering out unwanted fields
+	// - Converting field formats
+	// - Renaming fields
+	// - Adding computed fields
+	//
+	// Example usage:
+	// - FlattenNestedFields("attributes")
+	// - Replacing GUIDs with human-readable fields
+	// - Enhancing fields with custom data from the API response.
+	// - Adding a relationships property to the root level from a nested object.
+	RecordTransformer func(node *ajson.Node) (map[string]any, error)
+	// MarshalFunc converts a list of map[string]any records into ReadResultRow format.
+	MarshalFunc func(records []map[string]any, fields []string) ([]ReadResultRow, error)
+	// MarshalFromNodeFunc converts a list of ajson.Node records into ReadResultRow format.
+	MarshalFromNodeFunc func(records []*ajson.Node, fields []string) ([]ReadResultRow, error)
 )
 
+// ProviderReadResponseType defines the types of records that ParseResult can process.
+// It determines which callback function is used for parsing.
+type ProviderReadResponseType interface {
+	map[string]any | *ajson.Node
+}
+
 // ParseResult parses the response from a provider into a ReadResult. A 2xx return type is assumed.
 // The sizeFunc returns the total number of records in the response.
-// The recordsFunc returns the records in the response.
-// The nextPageFunc returns the URL for the next page of results.
+// The extractRecords returns the records in the response.
+// The extractNextPage returns the URL for the next page of results.
 // The marshalFunc is used to structure the data into an array of ReadResultRows.
 // The fields are used to populate ReadResultRow.Fields.
-func ParseResult(
+func ParseResult[R ProviderReadResponseType](
 	resp *JSONHTTPResponse,
-	recordsFunc func(*ajson.Node) ([]map[string]any, error),
-	nextPageFunc func(*ajson.Node) (string, error),
-	marshalFunc func([]map[string]any, []string) ([]ReadResultRow, error),
+	extractRecords func(*ajson.Node) ([]R, error),
+	extractNextPage func(*ajson.Node) (string, error),
+	marshalFunc func([]R, []string) ([]ReadResultRow, error),
 	fields datautils.Set[string],
 ) (*ReadResult, error) {
 	body, ok := resp.Body()
@@ -33,12 +65,12 @@ func ParseResult(
 		return nil, ErrEmptyJSONHTTPResponse
 	}
 
-	records, err := recordsFunc(body)
+	records, err := extractRecords(body)
 	if err != nil {
 		return nil, err
 	}
 
-	nextPage, err := nextPageFunc(body)
+	nextPage, err := extractNextPage(body)
 	if err != nil {
 		return nil, err
 	}
@@ -75,11 +107,11 @@ func ParseResult(
 
 // ExtractLowercaseFieldsFromRaw returns a map of fields from a record.
 // The fields are all returned in lowercase.
-func ExtractLowercaseFieldsFromRaw(fields []string, record map[string]interface{}) map[string]interface{} {
-	out := make(map[string]interface{}, len(fields))
+func ExtractLowercaseFieldsFromRaw(fields []string, record map[string]any) map[string]any {
+	out := make(map[string]any, len(fields))
 
 	// Modify all record keys to lowercase
-	lowercaseRecord := make(map[string]interface{}, len(record))
+	lowercaseRecord := make(map[string]any, len(record))
 	for key, value := range record {
 		lowercaseRecord[strings.ToLower(key)] = value
 	}
@@ -143,15 +175,43 @@ func GetMarshalledDataWithId(records []map[string]any, fields []string) ([]ReadR
 	return data, nil
 }
 
-func GetRecordsUnderJSONPath(jsonPath string, nestedPath ...string) RecordsFunc {
-	return getRecords(false, jsonPath, nestedPath...)
+// MakeMarshaledDataFunc produces ReadResultRow where raw record differs from the fields.
+// This usually includes a set of actions to preprocess, usually to flatten the raw record and then extract
+// fields requested by the user.
+func MakeMarshaledDataFunc(nodeRecordFunc RecordTransformer) MarshalFromNodeFunc {
+	return func(records []*ajson.Node, fields []string) ([]ReadResultRow, error) {
+		data := make([]ReadResultRow, len(records))
+
+		for index, nodeRecord := range records {
+			raw, err := jsonquery.Convertor.ObjectToMap(nodeRecord)
+			if err != nil {
+				return nil, err
+			}
+
+			record, err := nodeRecordFunc(nodeRecord)
+			if err != nil {
+				return nil, err
+			}
+
+			data[index] = ReadResultRow{
+				Fields: ExtractLowercaseFieldsFromRaw(fields, record),
+				Raw:    raw,
+			}
+		}
+
+		return data, nil
+	}
 }
 
-func GetOptionalRecordsUnderJSONPath(jsonPath string, nestedPath ...string) RecordsFunc {
-	return getRecords(true, jsonPath, nestedPath...)
+func ExtractRecordsFromPath(jsonPath string, nestedPath ...string) RecordsFunc {
+	return extractRecords(false, jsonPath, nestedPath...)
 }
 
-func getRecords(optional bool, jsonPath string, nestedPath ...string) RecordsFunc {
+func ExtractOptionalRecordsFromPath(jsonPath string, nestedPath ...string) RecordsFunc {
+	return extractRecords(true, jsonPath, nestedPath...)
+}
+
+func extractRecords(optional bool, jsonPath string, nestedPath ...string) RecordsFunc {
 	return func(node *ajson.Node) ([]map[string]any, error) {
 		var (
 			arr []*ajson.Node
@@ -171,3 +231,54 @@ func getRecords(optional bool, jsonPath string, nestedPath ...string) RecordsFun
 		return jsonquery.Convertor.ArrayToMap(arr)
 	}
 }
+
+// FlattenNestedFields returns a procedure which copies fields of a nested object to the top level.
+//
+// Ex: Every object has special field "attributes" which holds all the object specific fields.
+// Therefore, nested "attributes" will be removed and fields will be moved to the top level of the object.
+//
+// Example accounts(shortened response):
+//
+//	 "data": [
+//	    {
+//	        "type": "",
+//	        "id": "",
+//	        "attributes": {
+//	            "test_account": false,
+//	            "contact_information": {},
+//	            "locale": ""
+//	        },
+//	        "links": {}
+//	    }
+//	],
+//
+// The resulting fields for the above will be: [ type, id, test_account, contact_information, locale, links ].
+func FlattenNestedFields(nestedKey string) RecordTransformer {
+	return func(node *ajson.Node) (map[string]any, error) {
+		attributes, err := jsonquery.New(node).ObjectOptional(nestedKey)
+		if err != nil {
+			return nil, err
+		}
+
+		root, err := jsonquery.Convertor.ObjectToMap(node)
+		if err != nil {
+			return nil, err
+		}
+
+		nested, err := jsonquery.Convertor.ObjectToMap(attributes)
+		if err != nil {
+			return nil, err
+		}
+
+		// Nested object will be removed.
+		delete(root, nestedKey)
+
+		// Fields from attributes are moved to the top level.
+		for key, value := range nested {
+			root[key] = value
+		}
+
+		// Root level has adopted fields from nested object.
+		return root, nil
+	}
+}
diff --git a/common/types.go b/common/types.go
index 62193bae..cd1cab67 100644
--- a/common/types.go
+++ b/common/types.go
@@ -396,6 +396,12 @@ type SubscriptionEvent interface {
 	EventTimeStampNano() (int64, error)
 }
 
+type SubscriptionUpdateEvent interface {
+	SubscriptionEvent
+	// GetUpdatedFields returns the fields that were updated in the event.
+	UpdatedFields() ([]string, error)
+}
+
 // Some providers send multiple events in a single webhook payload.
 // This interface is used to extract individual events to SubscriptionEvent type
 // from a collapsed event for webhook parsing and processing.
diff --git a/internal/datautils/lists.go b/internal/datautils/lists.go
index bc31d9aa..a71cbac4 100644
--- a/internal/datautils/lists.go
+++ b/internal/datautils/lists.go
@@ -72,13 +72,3 @@ func (l UniqueLists[ID, V]) GetBuckets() []ID {
 
 	return result
 }
-
-func (l IndexedLists[ID, V]) MergeWith(other IndexedLists[ID, V]) {
-	for k, v := range other {
-		l.Add(k, v...)
-	}
-}
-
-func (l NamedLists[V]) MergeWith(other NamedLists[V]) {
-	IndexedLists[string, V](l).MergeWith(IndexedLists[string, V](other))
-}
diff --git a/internal/datautils/merge.go b/internal/datautils/merge.go
new file mode 100644
index 00000000..d15f8866
--- /dev/null
+++ b/internal/datautils/merge.go
@@ -0,0 +1,40 @@
+package datautils
+
+// MergeIndexedLists combines IndexedLists into one.
+func MergeIndexedLists[ID comparable, V any](lists ...IndexedLists[ID, V]) IndexedLists[ID, V] {
+	result := make(IndexedLists[ID, V])
+
+	for _, collection := range lists {
+		for key, value := range collection {
+			result.Add(key, value...)
+		}
+	}
+
+	return result
+}
+
+// MergeNamedLists combines NamedLists into one.
+func MergeNamedLists[V any](lists ...NamedLists[V]) NamedLists[V] {
+	result := make(NamedLists[V])
+
+	for _, collection := range lists {
+		for key, value := range collection {
+			result.Add(key, value...)
+		}
+	}
+
+	return result
+}
+
+// MergeUniqueLists combines UniqueLists into one.
+func MergeUniqueLists[ID, V comparable](lists ...UniqueLists[ID, V]) UniqueLists[ID, V] {
+	result := make(UniqueLists[ID, V])
+
+	for _, collection := range lists {
+		for key, value := range collection {
+			result.Add(key, value.List()...)
+		}
+	}
+
+	return result
+}
diff --git a/internal/goutils/utils.go b/internal/goutils/utils.go
index ca7a4ddb..6997a837 100644
--- a/internal/goutils/utils.go
+++ b/internal/goutils/utils.go
@@ -1,3 +1,4 @@
+// nolint:ireturn
 package goutils
 
 // Pointer returns a pointer to the given value.
@@ -23,3 +24,9 @@ func PanicRecovery(wrapup func(cause error)) {
 		wrapup(err)
 	}
 }
+
+// Identity returns the input value unchanged.
+// It acts as an identity function for any type.
+func Identity[T any](input T) T {
+	return input
+}
diff --git a/providers/aha/handlers.go b/providers/aha/handlers.go
index 470cef4e..99e801b9 100644
--- a/providers/aha/handlers.go
+++ b/providers/aha/handlers.go
@@ -64,7 +64,7 @@ func (c *Connector) parseReadResponse(
 
 	return common.ParseResult(
 		response,
-		common.GetRecordsUnderJSONPath(responseKey),
+		common.ExtractRecordsFromPath(responseKey),
 		makeNextRecordsURL(baseURL, params),
 		common.GetMarshaledData,
 		params.Fields,
diff --git a/providers/asana/read.go b/providers/asana/read.go
index 85c90955..79db25de 100644
--- a/providers/asana/read.go
+++ b/providers/asana/read.go
@@ -30,7 +30,7 @@ func (c *Connector) Read(ctx context.Context, config common.ReadParams) (*common
 
 	return common.ParseResult(
 		rsp,
-		common.GetOptionalRecordsUnderJSONPath("data"),
+		common.ExtractOptionalRecordsFromPath("data"),
 		makeNextRecordsURL(),
 		common.GetMarshaledData,
 		config.Fields,
diff --git a/providers/ashby/handlers.go b/providers/ashby/handlers.go
index e84529b3..4acc6f06 100644
--- a/providers/ashby/handlers.go
+++ b/providers/ashby/handlers.go
@@ -66,7 +66,7 @@ func (c *Connector) parseReadResponse(
 
 	return common.ParseResult(
 		response,
-		common.GetRecordsUnderJSONPath(responseKey),
+		common.ExtractRecordsFromPath(responseKey),
 		makeNextRecordsURL,
 		common.GetMarshaledData,
 		params.Fields,
diff --git a/providers/atlassian/parse.go b/providers/atlassian/parse.go
index 663a08ac..e1a207b2 100644
--- a/providers/atlassian/parse.go
+++ b/providers/atlassian/parse.go
@@ -30,36 +30,30 @@ Visual example of what will happen to each property:
 		}
 	}
 */
-func getRecords(node *ajson.Node) ([]map[string]any, error) {
-	arr, err := jsonquery.New(node).ArrayRequired("issues")
+func flattenRecord(item *ajson.Node) (map[string]any, error) {
+	fieldsObject, err := jsonquery.New(item).ObjectRequired("fields")
 	if err != nil {
-		return nil, err
+		return nil, errors.Join(common.ErrParseError, err)
 	}
 
-	list := make([]map[string]any, len(arr))
-
-	for index, item := range arr {
-		fieldsObject, err := jsonquery.New(item).ObjectRequired("fields")
-		if err != nil {
-			return nil, errors.Join(common.ErrParseError, err)
-		}
+	fields, err := jsonquery.Convertor.ObjectToMap(fieldsObject)
+	if err != nil {
+		return nil, errors.Join(common.ErrParseError, err)
+	}
 
-		fields, err := jsonquery.Convertor.ObjectToMap(fieldsObject)
-		if err != nil {
-			return nil, errors.Join(common.ErrParseError, err)
-		}
+	id, err := jsonquery.New(item).StringRequired("id")
+	if err != nil {
+		return nil, errors.Join(common.ErrParseError, err)
+	}
 
-		id, err := jsonquery.New(item).StringRequired("id")
-		if err != nil {
-			return nil, errors.Join(common.ErrParseError, err)
-		}
+	// Enhance response with id property.
+	fields["id"] = id
 
-		// Enhance response with id property.
-		fields["id"] = id
-		list[index] = fields
-	}
+	return fields, nil
+}
 
-	return list, nil
+func getRecords(node *ajson.Node) ([]*ajson.Node, error) {
+	return jsonquery.New(node).ArrayRequired("issues")
 }
 
 // Next starting page index is calculated base on current index and array size.
diff --git a/providers/atlassian/read.go b/providers/atlassian/read.go
index 491007a2..eccc1032 100644
--- a/providers/atlassian/read.go
+++ b/providers/atlassian/read.go
@@ -33,7 +33,7 @@ func (c *Connector) Read(ctx context.Context, config common.ReadParams) (*common
 		rsp,
 		getRecords,
 		getNextRecords,
-		common.GetMarshaledData,
+		common.MakeMarshaledDataFunc(flattenRecord),
 		config.Fields,
 	)
 }
diff --git a/providers/attio/read.go b/providers/attio/read.go
index a439e9b5..0bdb3690 100644
--- a/providers/attio/read.go
+++ b/providers/attio/read.go
@@ -34,7 +34,7 @@ func (c *Connector) readAPI(ctx context.Context, config common.ReadParams) (*com
 
 	return common.ParseResult(
 		rsp,
-		common.GetRecordsUnderJSONPath("data"),
+		common.ExtractRecordsFromPath("data"),
 		makeNextRecordsURL(url, config.ObjectName),
 		common.GetMarshaledData,
 		config.Fields,
@@ -65,7 +65,7 @@ func (c *Connector) readStandardOrCustomObject(
 
 	return common.ParseResult(
 		rsp,
-		common.GetRecordsUnderJSONPath("data"),
+		common.ExtractRecordsFromPath("data"),
 		makeNextRecordStandardObj(offset),
 		DataMarshall(rsp),
 		config.Fields,
diff --git a/providers/brevo.go b/providers/brevo.go
index 1992e379..012b4331 100644
--- a/providers/brevo.go
+++ b/providers/brevo.go
@@ -34,9 +34,9 @@ func init() {
 				Delete: false,
 			},
 			Proxy:     true,
-			Read:      false,
+			Read:      true,
 			Subscribe: false,
-			Write:     false,
+			Write:     true,
 		},
 	})
 }
diff --git a/providers/brevo/handlers.go b/providers/brevo/handlers.go
index 44d65dbb..f86beac4 100644
--- a/providers/brevo/handlers.go
+++ b/providers/brevo/handlers.go
@@ -62,7 +62,7 @@ func (c *Connector) parseReadResponse(
 
 	return common.ParseResult(
 		response,
-		common.GetRecordsUnderJSONPath(responseFieldName),
+		common.ExtractRecordsFromPath(responseFieldName),
 		nextRecordsURL(requestURL),
 		common.GetMarshaledData,
 		params.Fields,
diff --git a/providers/chilipiper/read.go b/providers/chilipiper/read.go
index a9f8b1cb..7cb64ff3 100644
--- a/providers/chilipiper/read.go
+++ b/providers/chilipiper/read.go
@@ -28,7 +28,7 @@ func (conn *Connector) Read(ctx context.Context, config common.ReadParams) (*com
 
 	return common.ParseResult(
 		resp,
-		common.GetRecordsUnderJSONPath("results"),
+		common.ExtractRecordsFromPath("results"),
 		nextRecordsURL(url),
 		common.GetMarshaledData,
 		config.Fields,
diff --git a/providers/clickup/handlers.go b/providers/clickup/handlers.go
index da7a85f9..75a546e0 100644
--- a/providers/clickup/handlers.go
+++ b/providers/clickup/handlers.go
@@ -43,7 +43,7 @@ func (c *Connector) parseReadResponse(
 
 	return common.ParseResult(
 		response,
-		common.GetRecordsUnderJSONPath(responseFieldName),
+		common.ExtractRecordsFromPath(responseFieldName),
 		nextRecordsURL(requestURL),
 		common.GetMarshaledData,
 		params.Fields,
diff --git a/providers/closecrm/read.go b/providers/closecrm/read.go
index 3d199631..e0330c88 100644
--- a/providers/closecrm/read.go
+++ b/providers/closecrm/read.go
@@ -45,7 +45,7 @@ func (c *Connector) Read(ctx context.Context, config common.ReadParams) (*common
 
 	return common.ParseResult(
 		resp,
-		common.GetRecordsUnderJSONPath("data"),
+		common.ExtractRecordsFromPath("data"),
 		nextRecordsURL(url),
 		common.GetMarshaledData,
 		config.Fields,
diff --git a/providers/closecrm/search.go b/providers/closecrm/search.go
index 247afc27..6874bbaa 100644
--- a/providers/closecrm/search.go
+++ b/providers/closecrm/search.go
@@ -46,7 +46,7 @@ func (c *Connector) Search(ctx context.Context, config SearchParams) (*common.Re
 
 	return common.ParseResult(
 		resp,
-		common.GetRecordsUnderJSONPath("data"),
+		common.ExtractRecordsFromPath("data"),
 		getNextRecordCursor,
 		common.GetMarshaledData,
 		datautils.NewStringSet(config.Fields...),
diff --git a/providers/constantcontact/read.go b/providers/constantcontact/read.go
index ee3f14d3..722c4d13 100644
--- a/providers/constantcontact/read.go
+++ b/providers/constantcontact/read.go
@@ -32,7 +32,7 @@ func (c *Connector) Read(ctx context.Context, config common.ReadParams) (*common
 	responseFieldName := metadata.Schemas.LookupArrayFieldName(c.Module.ID, config.ObjectName)
 
 	return common.ParseResult(res,
-		common.GetOptionalRecordsUnderJSONPath(responseFieldName),
+		common.ExtractOptionalRecordsFromPath(responseFieldName),
 		makeNextRecordsURL(c.BaseURL),
 		common.GetMarshaledData,
 		config.Fields,
diff --git a/providers/customerapp/read.go b/providers/customerapp/read.go
index fa298aec..c5c9e448 100644
--- a/providers/customerapp/read.go
+++ b/providers/customerapp/read.go
@@ -30,7 +30,7 @@ func (c *Connector) Read(ctx context.Context, config common.ReadParams) (*common
 	responseFieldName := metadata.Schemas.LookupArrayFieldName(c.Module.ID, config.ObjectName)
 
 	return common.ParseResult(res,
-		common.GetOptionalRecordsUnderJSONPath(responseFieldName),
+		common.ExtractOptionalRecordsFromPath(responseFieldName),
 		makeNextRecordsURL(url),
 		common.GetMarshaledData,
 		config.Fields,
diff --git a/providers/dynamicscrm/metadataService.go b/providers/dynamicscrm/metadataService.go
index 41c5cc8d..a4fbd98d 100644
--- a/providers/dynamicscrm/metadataService.go
+++ b/providers/dynamicscrm/metadataService.go
@@ -87,10 +87,11 @@ func combineAttributesMetadata(
 ) map[string]common.FieldMetadata {
 	// Regardless of the attribute type merge them into single registry.
 	// It is a `list of field values` each named after an `attribute`.
-	attributeOptions := make(datautils.NamedLists[common.FieldValue])
-	attributeOptions.MergeWith(attributePicklists.getOptionsPerAttribute())
-	attributeOptions.MergeWith(attributeStatuses.getOptionsPerAttribute())
-	attributeOptions.MergeWith(attributeStates.getOptionsPerAttribute())
+	attributeOptions := datautils.MergeNamedLists(
+		attributePicklists.getOptionsPerAttribute(),
+		attributeStatuses.getOptionsPerAttribute(),
+		attributeStates.getOptionsPerAttribute(),
+	)
 
 	fieldsMap := make(map[string]common.FieldMetadata)
 
diff --git a/providers/freshdesk/read.go b/providers/freshdesk/read.go
index 5e4bd140..d9c1628a 100644
--- a/providers/freshdesk/read.go
+++ b/providers/freshdesk/read.go
@@ -26,7 +26,7 @@ func (conn *Connector) Read(ctx context.Context, config common.ReadParams) (*com
 
 	return common.ParseResult(
 		resp,
-		common.GetRecordsUnderJSONPath(""), // we're reading the current node.
+		common.ExtractRecordsFromPath(""), // we're reading the current node.
 		nextRecordsURL(linkHeader),
 		common.GetMarshaledData,
 		config.Fields,
diff --git a/providers/front/handlers.go b/providers/front/handlers.go
index ae2cfc3e..33145c7f 100644
--- a/providers/front/handlers.go
+++ b/providers/front/handlers.go
@@ -42,7 +42,7 @@ func (c *Connector) parseReadResponse(
 ) (*common.ReadResult, error) {
 	return common.ParseResult(
 		response,
-		common.GetRecordsUnderJSONPath(dataResponseKey),
+		common.ExtractRecordsFromPath(dataResponseKey),
 		nextRecordsURL(),
 		common.GetMarshaledData,
 		params.Fields,
diff --git a/providers/gong/read.go b/providers/gong/read.go
index 5c636132..397896b1 100644
--- a/providers/gong/read.go
+++ b/providers/gong/read.go
@@ -48,7 +48,7 @@ func (c *Connector) Read(ctx context.Context, config common.ReadParams) (*common
 	responseFieldName := metadata.Schemas.LookupArrayFieldName(c.Module.ID, config.ObjectName)
 
 	return common.ParseResult(res,
-		common.GetRecordsUnderJSONPath(responseFieldName),
+		common.ExtractRecordsFromPath(responseFieldName),
 		getNextRecordsURL,
 		common.GetMarshaledData,
 		config.Fields,
diff --git a/providers/gorgias/parse.go b/providers/gorgias/parse.go
index 053b1a17..a6aa1692 100644
--- a/providers/gorgias/parse.go
+++ b/providers/gorgias/parse.go
@@ -17,7 +17,7 @@ func records(objectName string) common.RecordsFunc {
 
 			return []map[string]any{data}, nil
 		default:
-			return common.GetRecordsUnderJSONPath(dataField)(node)
+			return common.ExtractRecordsFromPath(dataField)(node)
 		}
 	}
 }
diff --git a/providers/groove/handlers.go b/providers/groove/handlers.go
index eb61f2dd..a3163d6c 100644
--- a/providers/groove/handlers.go
+++ b/providers/groove/handlers.go
@@ -116,7 +116,7 @@ func (c *Connector) parseReadResponse(
 ) (*common.ReadResult, error) {
 	return common.ParseResult(
 		response,
-		common.GetRecordsUnderJSONPath(responseField(params.ObjectName)),
+		common.ExtractRecordsFromPath(responseField(params.ObjectName)),
 		nextRecordsURL(),
 		common.GetMarshaledData,
 		params.Fields,
diff --git a/providers/hubspot/search.go b/providers/hubspot/search.go
index 6edbb220..81295f6a 100644
--- a/providers/hubspot/search.go
+++ b/providers/hubspot/search.go
@@ -82,7 +82,7 @@ func (c *Connector) searchCRM(
 
 	return common.ParseResult(
 		rsp,
-		common.GetOptionalRecordsUnderJSONPath(config.ObjectName),
+		common.ExtractOptionalRecordsFromPath(config.ObjectName),
 		getNextRecordsURLCRM,
 		common.GetMarshaledData,
 		config.Fields,
diff --git a/providers/hubspot/subscriptionEvent.go b/providers/hubspot/subscriptionEvent.go
index 888e2034..9c20d4bb 100644
--- a/providers/hubspot/subscriptionEvent.go
+++ b/providers/hubspot/subscriptionEvent.go
@@ -31,7 +31,10 @@ This form also prevents null fields to be sent out as zero values.
 */
 type SubscriptionEvent map[string]any
 
-var _ common.SubscriptionEvent = SubscriptionEvent{}
+var (
+	_ common.SubscriptionEvent       = SubscriptionEvent{}
+	_ common.SubscriptionUpdateEvent = SubscriptionEvent{}
+)
 
 // VerifyWebhookMessage verifies the signature of a webhook message from Hubspot.
 func (*Connector) VerifyWebhookMessage(
@@ -166,6 +169,17 @@ func (evt SubscriptionEvent) ObjectTypeId() (string, error) {
 	return m.GetString("objectTypeId")
 }
 
+func (evt SubscriptionEvent) UpdatedFields() ([]string, error) {
+	m := evt.asMap()
+
+	property, err := m.GetString("propertyName")
+	if err != nil {
+		return nil, fmt.Errorf("error getting property name: %w", err)
+	}
+
+	return []string{property}, nil
+}
+
 /*
 	EXAMPLES: There is no documentation that shows data structure of subscription event.
 	Below examples were found from hubspot app settings page after login at:
diff --git a/providers/instantly/read.go b/providers/instantly/read.go
index 2d8cb23a..b99a4846 100644
--- a/providers/instantly/read.go
+++ b/providers/instantly/read.go
@@ -34,7 +34,7 @@ func (c *Connector) Read(ctx context.Context, config common.ReadParams) (*common
 
 	return common.ParseResult(
 		rsp,
-		common.GetRecordsUnderJSONPath(nodePath),
+		common.ExtractRecordsFromPath(nodePath),
 		makeNextRecordsURL(url),
 		common.GetMarshaledData,
 		config.Fields,
diff --git a/providers/iterable/parse.go b/providers/iterable/parse.go
index 3cda77ac..fa3e3fb6 100644
--- a/providers/iterable/parse.go
+++ b/providers/iterable/parse.go
@@ -18,7 +18,7 @@ func makeGetRecords(moduleID common.ModuleID, objectName string) common.RecordsF
 			nestedPath = []string{"params"}
 		}
 
-		return common.GetOptionalRecordsUnderJSONPath(responseFieldName, nestedPath...)(node)
+		return common.ExtractOptionalRecordsFromPath(responseFieldName, nestedPath...)(node)
 	}
 }
 
diff --git a/providers/keap/parse.go b/providers/keap/parse.go
index a6859a1d..eb2757dd 100644
--- a/providers/keap/parse.go
+++ b/providers/keap/parse.go
@@ -1,13 +1,20 @@
 package keap
 
 import (
-	"context"
-
 	"github.com/amp-labs/connectors/common"
 	"github.com/amp-labs/connectors/internal/jsonquery"
+	"github.com/amp-labs/connectors/providers/keap/metadata"
 	"github.com/spyzhov/ajson"
 )
 
+func makeGetRecords(moduleID common.ModuleID, objectName string) common.NodeRecordsFunc {
+	return func(node *ajson.Node) ([]*ajson.Node, error) {
+		responseFieldName := metadata.Schemas.LookupArrayFieldName(moduleID, objectName)
+
+		return jsonquery.New(node).ArrayOptional(responseFieldName)
+	}
+}
+
 func makeNextRecordsURL(moduleID common.ModuleID) common.NextPageFunc {
 	return func(node *ajson.Node) (string, error) {
 		if moduleID == ModuleV1 {
@@ -21,25 +28,14 @@ func makeNextRecordsURL(moduleID common.ModuleID) common.NextPageFunc {
 // Before parsing the records, if any custom fields are present (without a human-readable name),
 // this will call the correct API to extend & replace the custom field with human-readable information.
 // Object will then be enhanced using model.
-func (c *Connector) parseReadRecords(
-	ctx context.Context, config common.ReadParams, jsonPath string,
-) common.RecordsFunc {
-	return func(node *ajson.Node) ([]map[string]any, error) {
-		arr, err := jsonquery.New(node).ArrayOptional(jsonPath)
-		if err != nil {
-			return nil, err
-		}
-
-		customFields, err := c.requestCustomFields(ctx, config.ObjectName)
-		if err != nil {
-			return nil, err
-		}
-
+func (c *Connector) attachReadCustomFields(customFields map[int]modelCustomField) common.RecordTransformer {
+	return func(node *ajson.Node) (map[string]any, error) {
 		if len(customFields) == 0 {
-			return jsonquery.Convertor.ArrayToMap(arr)
+			// No custom fields, no-op, return as is.
+			return jsonquery.Convertor.ObjectToMap(node)
 		}
 
-		return enhanceObjectsWithCustomFieldNames(arr, customFields)
+		return enhanceObjectsWithCustomFieldNames(node, customFields)
 	}
 }
 
@@ -49,31 +45,25 @@ func (c *Connector) parseReadRecords(
 // * Replace ids with human-readable names, which is provided as argument.
 // * Place fields at the top level of the object.
 func enhanceObjectsWithCustomFieldNames(
-	arr []*ajson.Node,
+	node *ajson.Node,
 	fields map[int]modelCustomField,
-) ([]map[string]any, error) {
-	result := make([]map[string]any, len(arr))
-
-	for index, node := range arr {
-		object, err := jsonquery.Convertor.ObjectToMap(node)
-		if err != nil {
-			return nil, err
-		}
+) (map[string]any, error) {
+	object, err := jsonquery.Convertor.ObjectToMap(node)
+	if err != nil {
+		return nil, err
+	}
 
-		customFieldsResponse, err := jsonquery.ParseNode[readCustomFieldsResponse](node)
-		if err != nil {
-			return nil, err
-		}
+	customFieldsResponse, err := jsonquery.ParseNode[readCustomFieldsResponse](node)
+	if err != nil {
+		return nil, err
+	}
 
-		// Replace identifiers with human-readable field names which were found by making a call to "/model".
-		for _, field := range customFieldsResponse.CustomFields {
-			if model, ok := fields[field.ID]; ok {
-				object[model.FieldName] = field.Content
-			}
+	// Replace identifiers with human-readable field names which were found by making a call to "/model".
+	for _, field := range customFieldsResponse.CustomFields {
+		if model, ok := fields[field.ID]; ok {
+			object[model.FieldName] = field.Content
 		}
-
-		result[index] = object
 	}
 
-	return result, nil
+	return object, nil
 }
diff --git a/providers/keap/read.go b/providers/keap/read.go
index 114282ed..60c6a2ad 100644
--- a/providers/keap/read.go
+++ b/providers/keap/read.go
@@ -39,12 +39,15 @@ func (c *Connector) Read(ctx context.Context, config common.ReadParams) (*common
 		return nil, err
 	}
 
-	responseFieldName := metadata.Schemas.LookupArrayFieldName(c.Module.ID, config.ObjectName)
+	customFields, err := c.requestCustomFields(ctx, config.ObjectName)
+	if err != nil {
+		return nil, err
+	}
 
 	return common.ParseResult(res,
-		c.parseReadRecords(ctx, config, responseFieldName),
+		makeGetRecords(c.Module.ID, config.ObjectName),
 		makeNextRecordsURL(c.Module.ID),
-		common.GetMarshaledData,
+		common.MakeMarshaledDataFunc(c.attachReadCustomFields(customFields)),
 		config.Fields,
 	)
 }
diff --git a/providers/kit/read.go b/providers/kit/read.go
index 93ed8439..919e9779 100644
--- a/providers/kit/read.go
+++ b/providers/kit/read.go
@@ -29,7 +29,7 @@ func (c *Connector) Read(ctx context.Context, config common.ReadParams) (*common
 
 	return common.ParseResult(
 		rsp,
-		common.GetRecordsUnderJSONPath(config.ObjectName),
+		common.ExtractRecordsFromPath(config.ObjectName),
 		makeNextRecordsURL(url),
 		common.GetMarshaledData,
 		config.Fields,
diff --git a/providers/klaviyo/parse.go b/providers/klaviyo/parse.go
index d7d8f82a..5948ef90 100644
--- a/providers/klaviyo/parse.go
+++ b/providers/klaviyo/parse.go
@@ -5,67 +5,8 @@ import (
 	"github.com/spyzhov/ajson"
 )
 
-// Every object has special field attributes which holds all the object specific fields.
-// Therefore, nested "attributes" will be removed and fields will be moved to the top level of the object.
-//
-// Example accounts(shortened response):
-//
-//	 "data": [
-//	    {
-//	        "type": "",
-//	        "id": "",
-//	        "attributes": {
-//	            "test_account": false,
-//	            "contact_information": {},
-//	            "locale": ""
-//	        },
-//	        "links": {}
-//	    }
-//	],
-//
-// The resulting fields for the above will be: type, id, test_account, contact_information, locale, links.
-func getRecords(node *ajson.Node) ([]map[string]any, error) {
-	arr, err := jsonquery.New(node).ArrayOptional("data")
-	if err != nil {
-		return nil, err
-	}
-
-	return flattenRecords(arr)
-}
-
-func flattenRecords(arr []*ajson.Node) ([]map[string]any, error) {
-	result := make([]map[string]any, len(arr))
-
-	for index, element := range arr {
-		const keyAttributes = "attributes"
-
-		attributes, err := jsonquery.New(element).ObjectOptional(keyAttributes)
-		if err != nil {
-			return nil, err
-		}
-
-		original, err := jsonquery.Convertor.ObjectToMap(element)
-		if err != nil {
-			return nil, err
-		}
-
-		nested, err := jsonquery.Convertor.ObjectToMap(attributes)
-		if err != nil {
-			return nil, err
-		}
-
-		// Attributes object must be removed.
-		delete(original, keyAttributes)
-
-		// Fields from attributes are moved to the top level.
-		for key, value := range nested {
-			original[key] = value
-		}
-
-		result[index] = original
-	}
-
-	return result, nil
+func getRecords(node *ajson.Node) ([]*ajson.Node, error) {
+	return jsonquery.New(node).ArrayOptional("data")
 }
 
 func getNextRecordsURL(node *ajson.Node) (string, error) {
diff --git a/providers/klaviyo/read.go b/providers/klaviyo/read.go
index cdbad278..77a88b51 100644
--- a/providers/klaviyo/read.go
+++ b/providers/klaviyo/read.go
@@ -31,7 +31,7 @@ func (c *Connector) Read(ctx context.Context, config common.ReadParams) (*common
 	return common.ParseResult(res,
 		getRecords,
 		getNextRecordsURL,
-		common.GetMarshaledData,
+		common.MakeMarshaledDataFunc(common.FlattenNestedFields("attributes")),
 		config.Fields,
 	)
 }
diff --git a/providers/lemlist/parse.go b/providers/lemlist/parse.go
index aea592e3..2c6655a9 100644
--- a/providers/lemlist/parse.go
+++ b/providers/lemlist/parse.go
@@ -28,7 +28,7 @@ func records(objectName string) common.RecordsFunc {
 			return []map[string]any{record}, err
 
 		default:
-			return common.GetRecordsUnderJSONPath(fld)(node)
+			return common.ExtractRecordsFromPath(fld)(node)
 		}
 	}
 }
diff --git a/providers/pipedrive/read.go b/providers/pipedrive/read.go
index dde6191d..bc80baa1 100644
--- a/providers/pipedrive/read.go
+++ b/providers/pipedrive/read.go
@@ -26,7 +26,7 @@ func (c *Connector) Read(ctx context.Context, config common.ReadParams) (*common
 	}
 
 	return common.ParseResult(resp,
-		common.GetOptionalRecordsUnderJSONPath("data"),
+		common.ExtractOptionalRecordsFromPath("data"),
 		nextRecordsURL(url),
 		common.GetMarshaledData,
 		config.Fields,
diff --git a/providers/podium/handlers.go b/providers/podium/handlers.go
index 91799479..fec8fd40 100644
--- a/providers/podium/handlers.go
+++ b/providers/podium/handlers.go
@@ -97,7 +97,7 @@ func (c *Connector) parseReadResponse(
 ) (*common.ReadResult, error) {
 	return common.ParseResult(
 		response,
-		common.GetOptionalRecordsUnderJSONPath(dataField),
+		common.ExtractOptionalRecordsFromPath(dataField),
 		nextRecordsURL(),
 		common.GetMarshaledData,
 		params.Fields,
diff --git a/providers/salesforce/subscriptionEvent.go b/providers/salesforce/subscriptionEvent.go
index 568ce759..7d55ed41 100644
--- a/providers/salesforce/subscriptionEvent.go
+++ b/providers/salesforce/subscriptionEvent.go
@@ -4,6 +4,7 @@ package salesforce
 import (
 	"context"
 	"errors"
+	"fmt"
 
 	"github.com/amp-labs/connectors/common"
 	"github.com/amp-labs/connectors/internal/goutils"
@@ -16,8 +17,10 @@ const (
 )
 
 var (
-	errChangeEventHeaderType = errors.New("key ChangeEventHeader is not of map[string]any type")
-	errRecordIDsType         = errors.New("key recordIds is not of []any type")
+	errChangeEventHeaderType     = errors.New("key ChangeEventHeader is not of map[string]any type")
+	errRecordIDsType             = errors.New("key recordIds is not of []any type")
+	errUnexpectedUpdateFieldType = errors.New("unexpected field type")
+	errUnexpectedFieldNameType   = errors.New("unexpected field name type")
 )
 
 func (*Connector) VerifyWebhookMessage(context.Context, *common.WebhookVerificationParameters) (bool, error) {
@@ -80,7 +83,10 @@ func (e CollapsedSubscriptionEvent) SubscriptionEventList() ([]common.Subscripti
 	return events, nil
 }
 
-var _ common.SubscriptionEvent = SubscriptionEvent{}
+var (
+	_ common.SubscriptionEvent       = SubscriptionEvent{}
+	_ common.SubscriptionUpdateEvent = SubscriptionEvent{}
+)
 
 // SubscriptionEvent holds event data.
 //
@@ -164,6 +170,42 @@ func (s SubscriptionEvent) EventTimeStampNano() (int64, error) {
 	return int64(num), nil
 }
 
+func (s SubscriptionEvent) UpdatedFields() ([]string, error) {
+	registry, err := s.asMap()
+	if err != nil {
+		return nil, fmt.Errorf("getting event objects: %w", err)
+	}
+
+	fieldsAny, ok := registry["changedFields"].([]any)
+	if !ok {
+		return nil, fmt.Errorf(
+			"%w: expected %T, but received %T ",
+			errUnexpectedUpdateFieldType,
+			fieldsAny,
+			registry["changedFields"],
+		)
+	}
+
+	fields := make([]string, len(fieldsAny))
+
+	//nolint:varnamelen
+	for i, field := range fieldsAny {
+		str, ok := field.(string)
+		if !ok {
+			return nil, fmt.Errorf(
+				"%w: expected %T but received %T",
+				errUnexpectedFieldNameType,
+				str,
+				field,
+			)
+		}
+
+		fields[i] = str
+	}
+
+	return fields, nil
+}
+
 func extractChangeEventHeader(registry map[string]any) (common.StringMap, error) {
 	eventMap := common.StringMap(registry)
 
diff --git a/providers/smartleadv2/handlers.go b/providers/smartleadv2/handlers.go
index bc9fd388..18e58247 100644
--- a/providers/smartleadv2/handlers.go
+++ b/providers/smartleadv2/handlers.go
@@ -30,7 +30,7 @@ func (c *Connector) parseReadResponse(
 ) (*common.ReadResult, error) {
 	return common.ParseResult(
 		resp,
-		common.GetOptionalRecordsUnderJSONPath(""),
+		common.ExtractOptionalRecordsFromPath(""),
 		getNextRecordsURL,
 		common.GetMarshaledData,
 		params.Fields,
diff --git a/providers/stripe/read.go b/providers/stripe/read.go
index 12d929c4..0eee9fae 100644
--- a/providers/stripe/read.go
+++ b/providers/stripe/read.go
@@ -38,7 +38,7 @@ func (c *Connector) Read(ctx context.Context, config common.ReadParams) (*common
 	responseFieldName := metadata.Schemas.LookupArrayFieldName(c.Module.ID, config.ObjectName)
 
 	return common.ParseResult(res,
-		common.GetOptionalRecordsUnderJSONPath(responseFieldName),
+		common.ExtractOptionalRecordsFromPath(responseFieldName),
 		makeNextRecordsURL(url),
 		common.GetMarshaledData,
 		config.Fields,
diff --git a/providers/zendeskchat/handlers.go b/providers/zendeskchat/handlers.go
index 407e6343..97845f47 100644
--- a/providers/zendeskchat/handlers.go
+++ b/providers/zendeskchat/handlers.go
@@ -139,7 +139,7 @@ func (c *Connector) parseReadResponse(
 ) (*common.ReadResult, error) {
 	return common.ParseResult(
 		response,
-		common.GetRecordsUnderJSONPath(responseField(params.ObjectName)),
+		common.ExtractRecordsFromPath(responseField(params.ObjectName)),
 		nextRecordsURL(params.ObjectName),
 		common.GetMarshaledData,
 		params.Fields,
diff --git a/providers/zendesksupport/read.go b/providers/zendesksupport/read.go
index d14b5d60..ccc12151 100644
--- a/providers/zendesksupport/read.go
+++ b/providers/zendesksupport/read.go
@@ -33,7 +33,7 @@ func (c *Connector) Read(ctx context.Context, config common.ReadParams) (*common
 
 	return common.ParseResult(
 		rsp,
-		common.GetRecordsUnderJSONPath(responseFieldName),
+		common.ExtractRecordsFromPath(responseFieldName),
 		getNextRecordsURL,
 		common.GetMarshaledData,
 		config.Fields,
diff --git a/providers/zohocrm/read.go b/providers/zohocrm/read.go
index 59e4a54c..9de0e348 100644
--- a/providers/zohocrm/read.go
+++ b/providers/zohocrm/read.go
@@ -27,7 +27,7 @@ func (c *Connector) Read(ctx context.Context, config common.ReadParams) (*common
 	}
 
 	return common.ParseResult(res,
-		common.GetRecordsUnderJSONPath("data"),
+		common.ExtractRecordsFromPath("data"),
 		getNextRecordsURL(url),
 		common.GetMarshaledData,
 		config.Fields,
diff --git a/providers/zoom/read.go b/providers/zoom/read.go
index 599dcc2d..3e19f3dc 100644
--- a/providers/zoom/read.go
+++ b/providers/zoom/read.go
@@ -32,7 +32,7 @@ func (c *Connector) Read(ctx context.Context, config common.ReadParams) (*common
 
 	return common.ParseResult(
 		rsp,
-		common.GetRecordsUnderJSONPath(responseFieldName),
+		common.ExtractRecordsFromPath(responseFieldName),
 		getNextRecordURL(url),
 		common.GetMarshaledData,
 		config.Fields,
diff --git a/tools/fileconv/api3/parameters.go b/tools/fileconv/api3/parameters.go
index 014b8d2a..be1f17a9 100644
--- a/tools/fileconv/api3/parameters.go
+++ b/tools/fileconv/api3/parameters.go
@@ -95,12 +95,32 @@ func OnlyOptionalQueryParameters(objectName string, operation *openapi3.Operatio
 // list of fields becoming "a", "b", "c", "d", "e".
 type PropertyFlattener func(objectName, fieldName string) bool
 
+// SingleItemDuplicatesResolver processes each endpoint individually.
+func SingleItemDuplicatesResolver(mapping func(string) string) DuplicatesResolver {
+	return func(collidingEndpoints [][]string) map[string]string {
+		result := make(map[string]string)
+
+		for _, endpoints := range collidingEndpoints {
+			for _, endpoint := range endpoints {
+				result[endpoint] = mapping(endpoint)
+			}
+		}
+
+		return result
+	}
+}
+
+// DuplicatesResolver processes groups of endpoints which collide among each other.
+// Returns a registry mapping each endpoint URL path to a unique, non-colliding object name.
+type DuplicatesResolver func(collidingEndpoints [][]string) map[string]string // endpoint to objectName
+
 type parameters struct {
 	displayPostProcessing DisplayNameProcessor
 	operationMethodFilter ReadOperationMethodFilter
 	propertyFlattener     PropertyFlattener
 	mediaType             string
 	autoSelectArrayItem   *bool
+	duplicatesResolver    DuplicatesResolver
 }
 
 type Option = func(params *parameters)
@@ -140,6 +160,11 @@ func createParams(opts []Option) *parameters {
 		params.autoSelectArrayItem = goutils.Pointer(false)
 	}
 
+	if params.duplicatesResolver == nil {
+		// Object name will be set to URL path.
+		params.duplicatesResolver = SingleItemDuplicatesResolver(goutils.Identity)
+	}
+
 	return &params
 }
 
@@ -194,3 +219,14 @@ func WithArrayItemAutoSelection() Option {
 		params.autoSelectArrayItem = goutils.Pointer(true)
 	}
 }
+
+// WithDuplicatesResolver enables custom object name creation based on endpoint paths.
+// If the last part of the URI conflicts with other endpoints, they are treated as duplicates,
+// and the resolver will be invoked to handle the collision.
+// When this option is not specified, the default resolver uses the full URL to identify an object,
+// ensuring no collisions.
+func WithDuplicatesResolver(duplicatesResolver DuplicatesResolver) Option {
+	return func(params *parameters) {
+		params.duplicatesResolver = duplicatesResolver
+	}
+}
diff --git a/tools/fileconv/api3/queries.go b/tools/fileconv/api3/queries.go
index 128bd94f..af4c1d0b 100644
--- a/tools/fileconv/api3/queries.go
+++ b/tools/fileconv/api3/queries.go
@@ -1,7 +1,6 @@
 package api3
 
 import (
-	"log/slog"
 	"sort"
 	"strings"
 
@@ -78,12 +77,12 @@ func (e Explorer[C]) ReadObjects(
 ) (metadatadef.Schemas[C], error) {
 	schemas := make(metadatadef.Schemas[C], 0)
 
-	pathItems, _ := e.GetPathItems(AndPathMatcher{
+	pathItems := e.GetPathItems(AndPathMatcher{
 		pathMatcher,
 		// There should be no curly brackets no IDs, no nested resources.
 		// Read objects are those that have constant string path.
 		IDPathIgnorer{},
-	}, objectEndpoints, true)
+	}, objectEndpoints)
 
 	for _, path := range pathItems {
 		schema, found, err := path.RetrieveSchemaOperation(operationName,
@@ -111,12 +110,12 @@ func (e Explorer[C]) ReadObjects(
 	return schemas, nil
 }
 
-// GetPathItems returns path items where object name is a single word. Second output is a list of duplicate path items.
+// GetPathItems returns path items where object name is a single word.
 func (e Explorer[C]) GetPathItems(
-	pathMatcher PathMatcher, endpointResources map[string]string, logDuplicates bool,
-) ([]*PathItem[C], datautils.UniqueLists[string, *PathItem[C]]) {
-	items := datautils.Map[string, *PathItem[C]]{}
-	duplicates := datautils.UniqueLists[string, *PathItem[C]]{}
+	pathMatcher PathMatcher, endpointResources map[string]string,
+) []*PathItem[C] {
+	items := datautils.Map[string, *PathItem[C]]{} // URL path to item
+	namedPaths := datautils.NamedLists[string]{}
 
 	for path, pathObj := range e.schema.GetPaths() {
 		if !pathMatcher.IsPathMatching(path) {
@@ -124,8 +123,8 @@ func (e Explorer[C]) GetPathItems(
 			continue
 		}
 
-		objectName, isDuplicate := endpointResources[path]
-		if !isDuplicate {
+		objectName, found := endpointResources[path]
+		if !found {
 			// ObjectName is empty at this time.
 			// We need to do some processing to infer ObjectName from URL path.
 			// By default, the last URL part is the ObjectName describing this REST resource.
@@ -133,42 +132,38 @@ func (e Explorer[C]) GetPathItems(
 			objectName = parts[len(parts)-1]
 		}
 
-		item := PathItem[C]{
+		items[path] = &PathItem[C]{
 			objectName: objectName,
 			urlPath:    path,
 			delegate:   pathObj,
 		}
 
-		if oldItem, exists := items[objectName]; exists {
-			duplicates.Add(objectName, oldItem) // existing item is a duplicate
-			duplicates.Add(objectName, &item)   // new item is considered a duplicate too
-		} else {
-			// Associate item with object name.
-			items[objectName] = &item
-		}
+		namedPaths.Add(objectName, path)
 	}
 
-	// Report PathItems that use the same object name.
-	// They will be excluded from schema extraction and script user will be warned to take action.
-	for objectName, pathItems := range duplicates {
-		paths := make([]string, len(pathItems))
-		for index, item := range pathItems.List() {
-			paths[index] = item.urlPath
-		}
+	// Items that have repeated names
+	collisions := make([][]string, 0)
 
-		if logDuplicates {
-			slog.Warn("object name is not unique, ignoring",
-				"objectName", objectName,
-				"collisions", strings.Join(paths, ", "),
-			)
+	for _, paths := range namedPaths {
+		if len(paths) > 1 {
+			collisions = append(collisions, paths)
 		}
+	}
+
+	result := datautils.Map[string, *PathItem[C]]{} // object name to item
 
-		// We have logged each path that shares the same object name.
-		// No such object will be included for consistency.
-		delete(items, objectName)
+	duplicatesMapping := e.duplicatesResolver(collisions)
+	for _, object := range items {
+		if nonCollidingName, wasDuplicate := duplicatesMapping[object.urlPath]; wasDuplicate {
+			// The name of this object was colliding with other objects.
+			object.objectName = nonCollidingName
+			result[nonCollidingName] = object
+		} else {
+			result[object.objectName] = object
+		}
 	}
 
-	return items.Values(), duplicates
+	return result.Values()
 }
 
 type EndpointOperations struct {
@@ -209,31 +204,24 @@ func (e Explorer[C]) GetEndpointOperations(
 ) ([]EndpointOperations, error) {
 	endpoints := make([]EndpointOperations, 0)
 
-	pathItems, duplicatePaths := e.GetPathItems(AndPathMatcher{pathMatcher, NestedIDPathIgnorer{}}, nil, false)
-	// We don't care about the object names. We want raw URL path.
-	// Since we are ignoring object names, the concept of duplicates is not relevant.
-	// Combine all paths into one registry. Look for paths that have operations of interest.
-	combinedPaths := duplicatePaths
-	combinedPaths.Add("", pathItems...)
+	pathItems := e.GetPathItems(AndPathMatcher{pathMatcher, NestedIDPathIgnorer{}}, nil)
 
-	for _, paths := range combinedPaths {
-		for path := range paths {
-			operations := datautils.Map[string, bool]{}
+	for _, path := range pathItems {
+		operations := datautils.Map[string, bool]{}
 
-			var found bool
+		var found bool
 
-			for _, operationName := range operationNames {
-				_, ok := path.selectOperation(operationName)
-				operations[operationName] = ok
-				found = found || ok // at least one operation should be found
-			}
+		for _, operationName := range operationNames {
+			_, ok := path.selectOperation(operationName)
+			operations[operationName] = ok
+			found = found || ok // at least one operation should be found
+		}
 
-			if found {
-				endpoints = append(endpoints, EndpointOperations{
-					URLPath:           path.urlPath,
-					OperationsSupport: operations,
-				})
-			}
+		if found {
+			endpoints = append(endpoints, EndpointOperations{
+				URLPath:           path.urlPath,
+				OperationsSupport: operations,
+			})
 		}
 	}
 


Test patch:
diff --git a/internal/datautils/merge_test.go b/internal/datautils/merge_test.go
new file mode 100644
index 00000000..b23bd52e
--- /dev/null
+++ b/internal/datautils/merge_test.go
@@ -0,0 +1,73 @@
+package datautils
+
+import (
+	"testing"
+
+	"github.com/amp-labs/connectors/test/utils/testutils"
+)
+
+func TestMergeUniqueLists(t *testing.T) { // nolint:funlen
+	t.Parallel()
+
+	type collection = UniqueLists[string, string]
+
+	tests := []struct {
+		name     string
+		input    []collection
+		expected collection
+	}{
+		{
+			name: "Colliding names are merged",
+			input: []collection{{
+				"fruits": NewSet("apple", "banana"),
+			}, {
+				"fruits": NewSet("apple", "orange"), // apple already exists, orange is new
+			}},
+			expected: collection{
+				"fruits": NewSet("apple", "banana", "orange"),
+			},
+		},
+		{
+			name: "Different named sets are propagated",
+			input: []collection{{
+				"numbers": NewSet("1", "2", "3"),
+			}, {
+				"letters": NewSet("A", "B", "C"), // new set
+			}},
+			expected: collection{
+				"numbers": NewSet("1", "2", "3"),
+				"letters": NewSet("A", "B", "C"),
+			},
+		},
+		{
+			name: "Combine diverse lists of sets",
+			input: []collection{
+				{
+					"digits":  NewSet("1", "2", "3"),
+					"decades": NewSet("10", "20"),
+				},
+				{
+					"digits":  NewSet("3", "4"), // 3 is duplicate
+					"decades": NewSet("30"),     // 30 is a new number
+					"grades":  NewSet("A", "B"), // new set
+				},
+			},
+			expected: collection{
+				"digits":  NewSet("1", "2", "3", "4"),
+				"decades": NewSet("10", "20", "30"),
+				"grades":  NewSet("A", "B"),
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		// nolint:varnamelen
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
+			output := MergeUniqueLists(tt.input...)
+
+			testutils.CheckOutput(t, tt.name, tt.expected, output)
+		})
+	}
+}
diff --git a/providers/atlassian/read_test.go b/providers/atlassian/read_test.go
index 5c060cf4..98ca067b 100644
--- a/providers/atlassian/read_test.go
+++ b/providers/atlassian/read_test.go
@@ -228,10 +228,8 @@ func TestRead(t *testing.T) { //nolint:funlen,gocognit,cyclop,maintidx
 						"summary": "Another one",
 					},
 					Raw: map[string]any{
-						"id":                       "10001",
-						"description":              nil,
-						"created":                  "2024-07-22T22:41:48.474+0300",
-						"statuscategorychangedate": "2024-07-22T22:41:48.686+0300",
+						"id":  "10001",
+						"key": "AM-2",
 					},
 				}, {
 					Fields: map[string]any{
@@ -239,10 +237,8 @@ func TestRead(t *testing.T) { //nolint:funlen,gocognit,cyclop,maintidx
 						"summary": "First Issue on Jira",
 					},
 					Raw: map[string]any{
-						"id":                       "10000",
-						"description":              nil,
-						"created":                  "2024-07-22T22:41:35.069+0300",
-						"statuscategorychangedate": "2024-07-22T22:41:35.326+0300",
+						"id":  "10000",
+						"key": "AM-1",
 					},
 				}},
 				NextPage: "2",
diff --git a/providers/keap/read_test.go b/providers/keap/read_test.go
index c295a6fc..1e7bdd82 100644
--- a/providers/keap/read_test.go
+++ b/providers/keap/read_test.go
@@ -75,7 +75,11 @@ func TestReadV1(t *testing.T) { //nolint:funlen,gocognit,cyclop,maintidx
 			Name: "Contacts first page has a link to next",
 			Input: common.ReadParams{
 				ObjectName: "contacts",
-				Fields:     connectors.Fields("given_name", "jobtitle"),
+				Fields: connectors.Fields("given_name",
+					// Next fields are custom fields which do NOT exist inside raw.
+					// However, they are surfaced to the user via ListObjectMetadata,
+					// so they will have context to request them.
+					"jobtitle", "jobdescription", "experience", "age"),
 			},
 			Server: mockserver.Switch{
 				Setup: mockserver.ContentJSON(),
@@ -92,15 +96,15 @@ func TestReadV1(t *testing.T) { //nolint:funlen,gocognit,cyclop,maintidx
 				Rows: 2,
 				Data: []common.ReadResultRow{{
 					Fields: map[string]any{
-						"given_name": "Erica",
-						"jobtitle":   "Product Owner",
-					},
-					Raw: map[string]any{
-						"id":             float64(22),
-						"family_name":    "Lewis",
+						"given_name":     "Erica",
+						"jobtitle":       "Product Owner",
 						"jobdescription": "AI application in commerce",
 						"experience":     "8 years in 3 companies",
 						"age":            float64(32),
+					},
+					Raw: map[string]any{
+						"id":          float64(22),
+						"family_name": "Lewis",
 						"custom_fields": []any{map[string]any{
 							"id":      float64(12),
 							"content": "8 years in 3 companies",
@@ -117,16 +121,16 @@ func TestReadV1(t *testing.T) { //nolint:funlen,gocognit,cyclop,maintidx
 					},
 				}, {
 					Fields: map[string]any{
-						"given_name": "John",
-						"jobtitle":   nil,
-					},
-					Raw: map[string]any{
-						"id":             float64(20),
-						"family_name":    "Doe",
+						"given_name":     "John",
+						"jobtitle":       nil,
 						"jobdescription": nil,
 						"experience":     nil,
 						"age":            nil,
 					},
+					Raw: map[string]any{
+						"id":          float64(20),
+						"family_name": "Doe",
+					},
 				}},
 				NextPage: "https://api.infusionsoft.com/crm/rest/v1/contacts/?limit=2&offset=2&since=2024-06-03T22:17:59.039Z&order=id", // nolint:lll
 				Done:     false,
diff --git a/providers/klaviyo/read_test.go b/providers/klaviyo/read_test.go
index b7c0a34e..363cbd2e 100644
--- a/providers/klaviyo/read_test.go
+++ b/providers/klaviyo/read_test.go
@@ -75,9 +75,24 @@ func TestRead(t *testing.T) { //nolint:funlen,gocognit,cyclop,maintidx
 						"email": "jennifer@gmail.com",
 					},
 					Raw: map[string]any{
-						"id":      "01HSXWNWF52J5PJG45BW383RMV",
-						"created": "2024-03-26T17:24:42+00:00",
-						"updated": "2024-03-26T17:24:42+00:00",
+						"id": "01HSXWNWF52J5PJG45BW383RMV",
+						"attributes": map[string]any{
+							"email":           "jennifer@gmail.com",
+							"phone_number":    nil,
+							"external_id":     nil,
+							"anonymous_id":    nil,
+							"first_name":      nil,
+							"last_name":       nil,
+							"organization":    nil,
+							"locale":          nil,
+							"title":           nil,
+							"image":           nil,
+							"created":         "2024-03-26T17:24:42+00:00",
+							"updated":         "2024-03-26T17:24:42+00:00",
+							"last_event_date": "2024-03-26T17:24:41+00:00",
+							"location":        map[string]any{},
+							"properties":      map[string]any{},
+						},
 					},
 				}},
 				NextPage: "https://a.klaviyo.com/api/profiles?page%5Bsize%5D=1&page%5Bcursor%5D=bmV4dDo6aWQ6OjAxSFNYV05XRjUySjVQSkc0NUJXMzgzUk1W", // nolint:lll
@@ -110,9 +125,19 @@ func TestRead(t *testing.T) { //nolint:funlen,gocognit,cyclop,maintidx
 						"name": "Email Campaign - Nov 15, 2024, 1:18 AM",
 					},
 					Raw: map[string]any{
-						"status":       "Scheduled",
-						"created_at":   "2024-11-14T23:18:34.827140+00:00",
-						"scheduled_at": "2024-11-14T23:20:02.718919+00:00",
+						"attributes": map[string]any{
+							"name":             "Email Campaign - Nov 15, 2024, 1:18 AM",
+							"status":           "Scheduled",
+							"archived":         false,
+							"audiences":        map[string]any{},
+							"send_options":     map[string]any{},
+							"tracking_options": map[string]any{},
+							"send_strategy":    map[string]any{},
+							"created_at":       "2024-11-14T23:18:34.827140+00:00",
+							"scheduled_at":     "2024-11-14T23:20:02.718919+00:00",
+							"updated_at":       "2024-11-14T23:20:32.232276+00:00",
+							"send_time":        "2024-11-30T18:15:00+00:00",
+						},
 					},
 				}},
 				NextPage: "",
diff --git a/providers/klaviyo/test/read-campaigns.json b/providers/klaviyo/test/read-campaigns.json
index c955b047..976f037f 100644
--- a/providers/klaviyo/test/read-campaigns.json
+++ b/providers/klaviyo/test/read-campaigns.json
@@ -1,65 +1,49 @@
 {
-  "data" : [ {
-    "type" : "campaign",
-    "id" : "01JCPFHB29QZ1NDPR3GCGQS5G2",
-    "attributes" : {
-      "name" : "Email Campaign - Nov 15, 2024, 1:18 AM",
-      "status" : "Scheduled",
-      "archived" : false,
-      "audiences" : {
-        "included" : [ "XzKvNz" ],
-        "excluded" : [ ]
+  "data": [
+    {
+      "type": "campaign",
+      "id": "01JCPFHB29QZ1NDPR3GCGQS5G2",
+      "attributes": {
+        "name": "Email Campaign - Nov 15, 2024, 1:18 AM",
+        "status": "Scheduled",
+        "archived": false,
+        "audiences": {},
+        "send_options": {},
+        "tracking_options": {},
+        "send_strategy": {},
+        "created_at": "2024-11-14T23:18:34.827140+00:00",
+        "scheduled_at": "2024-11-14T23:20:02.718919+00:00",
+        "updated_at": "2024-11-14T23:20:32.232276+00:00",
+        "send_time": "2024-11-30T18:15:00+00:00"
       },
-      "send_options" : {
-        "use_smart_sending" : true,
-        "ignore_unsubscribes" : false
-      },
-      "tracking_options" : {
-        "add_tracking_params" : false,
-        "custom_tracking_params" : [ ],
-        "is_tracking_clicks" : true,
-        "is_tracking_opens" : true
-      },
-      "send_strategy" : {
-        "method" : "static",
-        "options_static" : {
-          "datetime" : "2024-11-30T18:15:00+00:00",
-          "is_local" : true,
-          "send_past_recipients_immediately" : true
+      "relationships": {
+        "campaign-messages": {
+          "data": [
+            {
+              "type": "campaign-message",
+              "id": "01JCPFHB2F7J0J2R2HC8GNNC3Z"
+            }
+          ],
+          "links": {
+            "self": "https://a.klaviyo.com/api/campaigns/01JCPFHB29QZ1NDPR3GCGQS5G2/relationships/campaign-messages/",
+            "related": "https://a.klaviyo.com/api/campaigns/01JCPFHB29QZ1NDPR3GCGQS5G2/campaign-messages/"
+          }
         },
-        "options_throttled" : null,
-        "options_sto" : null
-      },
-      "created_at" : "2024-11-14T23:18:34.827140+00:00",
-      "scheduled_at" : "2024-11-14T23:20:02.718919+00:00",
-      "updated_at" : "2024-11-14T23:20:32.232276+00:00",
-      "send_time" : "2024-11-30T18:15:00+00:00"
-    },
-    "relationships" : {
-      "campaign-messages" : {
-        "data" : [ {
-          "type" : "campaign-message",
-          "id" : "01JCPFHB2F7J0J2R2HC8GNNC3Z"
-        } ],
-        "links" : {
-          "self" : "https://a.klaviyo.com/api/campaigns/01JCPFHB29QZ1NDPR3GCGQS5G2/relationships/campaign-messages/",
-          "related" : "https://a.klaviyo.com/api/campaigns/01JCPFHB29QZ1NDPR3GCGQS5G2/campaign-messages/"
+        "tags": {
+          "links": {
+            "self": "https://a.klaviyo.com/api/campaigns/01JCPFHB29QZ1NDPR3GCGQS5G2/relationships/tags/",
+            "related": "https://a.klaviyo.com/api/campaigns/01JCPFHB29QZ1NDPR3GCGQS5G2/tags/"
+          }
         }
       },
-      "tags" : {
-        "links" : {
-          "self" : "https://a.klaviyo.com/api/campaigns/01JCPFHB29QZ1NDPR3GCGQS5G2/relationships/tags/",
-          "related" : "https://a.klaviyo.com/api/campaigns/01JCPFHB29QZ1NDPR3GCGQS5G2/tags/"
-        }
+      "links": {
+        "self": "https://a.klaviyo.com/api/campaigns/01JCPFHB29QZ1NDPR3GCGQS5G2/"
       }
-    },
-    "links" : {
-      "self" : "https://a.klaviyo.com/api/campaigns/01JCPFHB29QZ1NDPR3GCGQS5G2/"
     }
-  } ],
-  "links" : {
-    "self" : "https://a.klaviyo.com/api/campaigns?filter=greater-than%28updated_at%2C2024-11-14T22%3A27%3A51Z%29%2Cequals%28messages.channel%2C%27email%27%29",
-    "next" : null,
-    "prev" : null
+  ],
+  "links": {
+    "self": "https://a.klaviyo.com/api/campaigns?filter=greater-than%28updated_at%2C2024-11-14T22%3A27%3A51Z%29%2Cequals%28messages.channel%2C%27email%27%29",
+    "next": null,
+    "prev": null
   }
 }
diff --git a/providers/klaviyo/test/read-profiles-1-first-page.json b/providers/klaviyo/test/read-profiles-1-first-page.json
index 70697307..6134e344 100644
--- a/providers/klaviyo/test/read-profiles-1-first-page.json
+++ b/providers/klaviyo/test/read-profiles-1-first-page.json
@@ -17,18 +17,7 @@
         "created": "2024-03-26T17:24:42+00:00",
         "updated": "2024-03-26T17:24:42+00:00",
         "last_event_date": "2024-03-26T17:24:41+00:00",
-        "location": {
-          "city": null,
-          "zip": null,
-          "country": null,
-          "longitude": null,
-          "address2": null,
-          "address1": null,
-          "region": null,
-          "latitude": null,
-          "timezone": null,
-          "ip": null
-        },
+        "location": {},
         "properties": {}
       },
       "relationships": {
diff --git a/test/aha/connector.go b/test/aha/connector.go
index 1c704bc4..4da52c0b 100644
--- a/test/aha/connector.go
+++ b/test/aha/connector.go
@@ -29,7 +29,6 @@ func GetAhaConnector(ctx context.Context) *aha.Connector {
 }
 
 func getConfig(reader *credscanning.ProviderCredentials) *oauth2.Config {
-
 	return &oauth2.Config{
 		ClientID:     reader.Get(credscanning.Fields.ClientId),
 		ClientSecret: reader.Get(credscanning.Fields.ClientSecret),
diff --git a/test/aha/write/main.go b/test/aha/write/main.go
index f9d06141..48df7a30 100644
--- a/test/aha/write/main.go
+++ b/test/aha/write/main.go
@@ -43,7 +43,6 @@ func main() {
 	if err := updateIdeaUsers(ctx, conn, recordId); err != nil {
 		slog.Error(err.Error())
 	}
-
 }
 
 func createProducts(ctx context.Context, conn *aha.Connector) error {
diff --git a/test/atlassian/connector.go b/test/atlassian/connector.go
index b65aaabd..9dc6fb32 100644
--- a/test/atlassian/connector.go
+++ b/test/atlassian/connector.go
@@ -12,7 +12,7 @@ import (
 	"golang.org/x/oauth2"
 )
 
-const cloudId = "ebc887b2-7e61-4059-ab35-71f15cc16e12"
+const cloudId = "35745fff-f0de-466c-b08e-a63f69888611"
 
 func GetAtlassianConnector(ctx context.Context) *atlassian.Connector {
 	filePath := credscanning.LoadPath(providers.Atlassian)
@@ -25,6 +25,7 @@ func GetAtlassianConnector(ctx context.Context) *atlassian.Connector {
 		atlassian.WithMetadata(map[string]string{
 			// This value can be obtained by following this API reference.
 			// https://developer.atlassian.com/cloud/confluence/oauth-2-3lo-apps/#3-1-get-the-cloudid-for-your-site
+			// Another simplest solution is to run `connectors/test/atlassian/auth-metadata/main.go` script.
 			"cloudId": cloudId,
 		}),
 	)
diff --git a/test/atlassian/read/main.go b/test/atlassian/read/main.go
index a56f7e55..acf44a3a 100644
--- a/test/atlassian/read/main.go
+++ b/test/atlassian/read/main.go
@@ -24,7 +24,8 @@ func main() {
 	conn := connTest.GetAtlassianConnector(ctx)
 
 	res, err := conn.Read(ctx, common.ReadParams{
-		Fields: connectors.Fields("id", "summary", "status"),
+		ObjectName: "issues",
+		Fields:     connectors.Fields("id", "summary", "status"),
 		// Below is the example to get issues that were updated in the last 15 min.
 		// Since: time.Now().Add(-15 * time.Minute),
 	})
diff --git a/test/brevo/read/main.go b/test/brevo/read/main.go
index 8063d9ab..d963e8f3 100644
--- a/test/brevo/read/main.go
+++ b/test/brevo/read/main.go
@@ -35,6 +35,7 @@ func main() {
 	utils.DumpJSON(res, os.Stdout)
 
 	slog.Info("Reading projects..")
+
 	res, err = conn.Read(ctx, common.ReadParams{
 		ObjectName: "attributes/deals",
 		Fields:     connectors.Fields("attributeOptions", "isRequired", "label"),
@@ -44,5 +45,4 @@ func main() {
 	}
 
 	utils.DumpJSON(res, os.Stdout)
-
 }
diff --git a/test/github/delete/main.go b/test/github/delete/main.go
index 7a66be93..f0378336 100644
--- a/test/github/delete/main.go
+++ b/test/github/delete/main.go
@@ -25,10 +25,10 @@ func main() {
 	conn := hs.GetGithubConnector(ctx)
 
 	slog.Info("> TEST Delete gists")
+
 	if err := deleteGist(ctx, conn); err != nil {
 		slog.Error(err.Error())
 	}
-
 }
 
 func deleteGist(ctx context.Context, conn *github.Connector) error {
diff --git a/test/github/write/main.go b/test/github/write/main.go
index cbf60bde..8b192ab1 100644
--- a/test/github/write/main.go
+++ b/test/github/write/main.go
@@ -31,15 +31,16 @@ func main() {
 	}
 
 	slog.Info("> TEST Create gists")
+
 	if err := createGist(ctx, conn); err != nil {
 		slog.Error(err.Error())
 	}
 
 	slog.Info("> TEST update user codespaces")
+
 	if err := updateUserCodespaces(ctx, conn); err != nil {
 		slog.Error(err.Error())
 	}
-
 }
 
 func addUserEmails(ctx context.Context, conn *github.Connector) error {
diff --git a/test/keap/read/main.go b/test/keap/read/main.go
index aad2610e..5a2af0bd 100644
--- a/test/keap/read/main.go
+++ b/test/keap/read/main.go
@@ -26,7 +26,7 @@ func main() {
 
 	res, err := conn.Read(ctx, common.ReadParams{
 		ObjectName: "contacts",
-		Fields:     connectors.Fields("id"),
+		Fields:     connectors.Fields("id", "experience"),
 		// Since:      time.Now().Add(-30 * time.Minute),
 		// NextPage: "https://api.infusionsoft.com/crm/rest/v1/contacts/?limit=1&offset=50&since=2024-12-17T21:39:36.099Z&order=id",
 	})
@@ -34,6 +34,6 @@ func main() {
 		utils.Fail("error reading from Keap", "error", err)
 	}
 
-	fmt.Println("Reading emails..")
+	fmt.Println("Reading contacts..")
 	utils.DumpJSON(res, os.Stdout)
 }