Repository: amp-labs/connectors
Pull request #1263 (2025-01-07 18:48:31)

chore: Add deep connector github PR template

Cobalt0s: * **#1264** <a href="https://app.graphite.dev/github/pr/amp-labs/connectors/1264?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* **#1263** <a href="https://app.graphite.dev/github/pr/amp-labs/connectors/1263?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a> 👈 <a href="https://app.graphite.dev/github/pr/amp-labs/connectors/1263?utm_source=stack-comment-view-in-graphite" target="_blank">(View in Graphite)</a>
* `main`


<h2></h2>

This stack of pull requests is managed by <a href="https://graphite.dev?utm-source=stack-comment"><b>Graphite</b></a>. Learn more about <a href="https://stacking.dev/?utm_source=stack-comment">stacking</a>.
<!-- Current dependencies on/for this PR: -->
Cobalt0s: @RajatPawar after addressing your comments:
![image](https://github.com/user-attachments/assets/d1ed9fe6-7857-47ce-bdf0-6279ae75431c)


Patch:
diff --git a/.github/PULL_REQUEST_TEMPLATE/deep_connector.md b/.github/PULL_REQUEST_TEMPLATE/deep_connector.md
new file mode 100644
index 00000000..2a0e6df8
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE/deep_connector.md
@@ -0,0 +1,16 @@
+# Installation 
+MailMonkey installation screenshot:
+![image](https://github.com/user-attachments/assets/ebc027fb-b82b-4505-ac71-f2b61209fa4d)
+
+# Read
+- [ ] Screenshot of operations on dashboard
+- [ ] Insert screenshot of metadata fields from read object installation.
+- [ ] Insert screenshot of Svix destination.
+
+# Write
+For each write object:
+- [ ] Insert screenshot of dashboard.
+- [ ] Insert screenshot of HTTP call.
+
+# Examples
+Link pull request to testdata repo which contains installation file: https://github.com/amp-labs/testdata
diff --git a/.github/PULL_REQUEST_TEMPLATE/new_connector.md b/.github/PULL_REQUEST_TEMPLATE/new_connector.md
index c1a699e1..86e1f965 100644
--- a/.github/PULL_REQUEST_TEMPLATE/new_connector.md
+++ b/.github/PULL_REQUEST_TEMPLATE/new_connector.md
@@ -1,5 +1,3 @@
-Closes #<issue-number>
-
 # Configuration
 <Any special connector notes>
 
diff --git a/.github/PULL_REQUEST_TEMPLATE/proxy_connector.md b/.github/PULL_REQUEST_TEMPLATE/proxy_connector.md
index 1ac8b989..6d681298 100644
--- a/.github/PULL_REQUEST_TEMPLATE/proxy_connector.md
+++ b/.github/PULL_REQUEST_TEMPLATE/proxy_connector.md
@@ -1,17 +1,15 @@
-Closes #<issue-number>
-
-## Testing
-### GET
+# Testing
+## GET
 URL: <localhost:4444/v2/some-api-call>
 Postman screenshot (must show the request URL, the response status code & body clearly)
 
-### POST
+## POST
 URL: <localhost:4444/v2/some-api-call>
 Postman screenshot (must show the request URL, the response status code & body clearly)
 
 <Please add the same information for all other methods available - PUT, PATCH, DELETE, etc>
 
-### Invalid requests
+## Invalid requests
 Wrong verb applied, invalid path.
 
 
@@ -21,5 +19,3 @@ Please add screenshots that show successful pagination using the connector.
 ## Successful console operation (operation & events)
 
 ## Failed console operation (operation & events)
-
-## Raw token response
\ No newline at end of file
diff --git a/common/error.go b/common/error.go
index 68e7f604..b084fc06 100644
--- a/common/error.go
+++ b/common/error.go
@@ -13,28 +13,36 @@ import (
 // InterpretError interprets the given HTTP response (in a fairly straightforward
 // way) and returns an error that can be handled by the caller.
 func InterpretError(res *http.Response, body []byte) error {
+	createError := func(err error) error {
+		if len(body) == 0 {
+			return err
+		} else {
+			return fmt.Errorf("%w: %s", err, string(body))
+		}
+	}
+
 	switch res.StatusCode {
 	case http.StatusUnauthorized:
 		// Access token invalid, refresh token and retry
-		return NewHTTPStatusError(res.StatusCode, fmt.Errorf("%w: %s", ErrAccessToken, string(body)))
+		return NewHTTPStatusError(res.StatusCode, createError(ErrAccessToken))
 	case http.StatusForbidden:
 		// Forbidden, not retryable
-		return NewHTTPStatusError(res.StatusCode, fmt.Errorf("%w: %s", ErrForbidden, string(body)))
+		return NewHTTPStatusError(res.StatusCode, createError(ErrForbidden))
 	case http.StatusNotFound:
 		// Semantics are debatable (temporarily missing vs. permanently gone), but for now treat this as a retryable error
-		return NewHTTPStatusError(res.StatusCode, fmt.Errorf("%w: %s", ErrRetryable, string(body)))
+		return NewHTTPStatusError(res.StatusCode, createError(ErrRetryable))
 	case http.StatusTooManyRequests:
 		// Too many requests, retryable
-		return NewHTTPStatusError(res.StatusCode, fmt.Errorf("%w: %s", ErrRetryable, string(body)))
+		return NewHTTPStatusError(res.StatusCode, createError(ErrRetryable))
 	}
 
 	if res.StatusCode >= 400 && res.StatusCode < 500 {
-		return NewHTTPStatusError(res.StatusCode, fmt.Errorf("%w: %s", ErrCaller, string(body)))
+		return NewHTTPStatusError(res.StatusCode, createError(ErrCaller))
 	} else if res.StatusCode >= 500 && res.StatusCode < 600 {
-		return NewHTTPStatusError(res.StatusCode, fmt.Errorf("%w: %s", ErrServer, string(body)))
+		return NewHTTPStatusError(res.StatusCode, createError(ErrServer))
 	}
 
-	return NewHTTPStatusError(res.StatusCode, fmt.Errorf("%w: %s", ErrUnknown, string(body)))
+	return NewHTTPStatusError(res.StatusCode, createError(ErrUnknown))
 }
 
 type ErrorPostProcessor struct {
diff --git a/common/types.go b/common/types.go
index 426fa106..8cc2af1f 100644
--- a/common/types.go
+++ b/common/types.go
@@ -186,12 +186,26 @@ type ReadResultRow struct {
 	// Fields is a map of requested provider field names to values.
 	// All field names are in lowercase (eg: accountid, name, billingcityid)
 	Fields map[string]any `json:"fields"`
+	// Associations is a map of associated objects to the main object.
+	// The key is the associated object name, and the value is an array of associated object ids.
+	Associations map[string][]Association `json:"associations,omitempty"`
 	// Raw is the raw JSON response from the provider.
 	Raw map[string]any `json:"raw"`
 	// RecordId is the ID of the record. Currently only populated for hubspot GetRecord and GetRecordsWithId function
 	Id string `json:"id,omitempty"`
 }
 
+// Association is a struct that represents an association between two objects.
+// If you think of an association as a directed edge between two nodes, then
+// the ObjectID is the target node, and the AssociationType is the type of edge.
+// The source node is represented by ReadResultRow.
+type Association struct {
+	// ObjectID is the ID of the associated object.
+	ObjectId string `json:"objectId"`
+	// AssociationType is the type of association.
+	AssociationType string `json:"associationType,omitempty"`
+}
+
 // WriteResult is what's returned from writing data via the Write call.
 type WriteResult struct {
 	// Success is true if write succeeded.
@@ -256,13 +270,52 @@ type ListObjectMetadataResult struct {
 }
 
 type ObjectMetadata struct {
-	// Provider's display name for the object
+	// Provider's display name for the object.
 	DisplayName string
 
-	// FieldsMap is a map of field names to field display names
+	// Fields is a map of field names to FieldMetadata.
+	Fields map[string]FieldMetadata
+
+	// FieldsMap is a map of field names to field display names.
+	// Deprecated: this map includes only display names.
+	// Refer to Fields for extended description of field properties.
 	FieldsMap map[string]string
 }
 
+// NewObjectMetadata constructs ObjectMetadata.
+// This will automatically infer fields map from field metadata map. This construct exists for such convenience.
+func NewObjectMetadata(displayName string, fields map[string]FieldMetadata) *ObjectMetadata {
+	return &ObjectMetadata{
+		DisplayName: displayName,
+		Fields:      fields,
+		FieldsMap:   inferDeprecatedFieldsMap(fields),
+	}
+}
+
+type FieldMetadata struct {
+	// DisplayName is a human-readable field name.
+	DisplayName string
+
+	// ValueType is a set of Ampersand defined field types.
+	ValueType ValueType
+
+	// ProviderType is the raw type, a term used by provider API.
+	// Each is mapped to an Ampersand ValueType.
+	ProviderType string
+
+	// ReadOnly would indicate if field can be modified or only read.
+	ReadOnly bool
+
+	// Values is a list of possible values for this field.
+	// It is applicable only if the type is either singleSelect or multiSelect, otherwise slice is nil.
+	Values []FieldValue
+}
+
+type FieldValue struct {
+	Value        string
+	DisplayValue string
+}
+
 type PostAuthInfo struct {
 	CatalogVars          *map[string]string
 	RawResponse          *JSONHTTPResponse
@@ -298,3 +351,13 @@ type WebhookVerificationParameters struct {
 	ClientSecret string
 	Method       string
 }
+
+func inferDeprecatedFieldsMap(fields map[string]FieldMetadata) map[string]string {
+	fieldsMap := make(map[string]string)
+
+	for name, field := range fields {
+		fieldsMap[name] = field.DisplayName
+	}
+
+	return fieldsMap
+}
diff --git a/common/valueType.go b/common/valueType.go
new file mode 100644
index 00000000..e3842dd1
--- /dev/null
+++ b/common/valueType.go
@@ -0,0 +1,18 @@
+package common
+
+type ValueType string
+
+const (
+	ValueTypeString  = "string"
+	ValueTypeBoolean = "boolean"
+	ValueTypeFloat   = "float" // float is more preferred than int if provider doesn't differentiate.
+	ValueTypeInt     = "int"
+
+	ValueTypeDate     = "date"
+	ValueTypeDateTime = "datetime"
+
+	ValueTypeSingleSelect = "singleSelect"
+	ValueTypeMultiSelect  = "multiSelect"
+
+	ValueTypeOther = "other"
+)
diff --git a/providers/hubspot/associations.go b/providers/hubspot/associations.go
new file mode 100644
index 00000000..04d52b03
--- /dev/null
+++ b/providers/hubspot/associations.go
@@ -0,0 +1,164 @@
+package hubspot
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"strconv"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/logging"
+)
+
+// Type definitions for HubSpot associations API.
+
+type assocInputs struct {
+	Inputs []assocId `json:"inputs"`
+}
+
+type assocId struct {
+	Id string `json:"id"`
+}
+
+type assocType struct {
+	Category string  `json:"category"`
+	TypeId   int     `json:"typeId"`
+	Label    *string `json:"label"`
+}
+
+type assocObject struct {
+	ToObjectId       int64       `json:"toObjectId"`
+	AssociationTypes []assocType `json:"associationTypes"`
+}
+
+type assocResult struct {
+	From assocId       `json:"from"`
+	To   []assocObject `json:"to"`
+}
+
+type assocOutput struct {
+	Status  string        `json:"status"`
+	Results []assocResult `json:"results"`
+}
+
+// String returns a string representation of the association type.
+func (t *assocType) String() string {
+	if t.Label != nil && len(*t.Label) > 0 {
+		return fmt.Sprintf("category=%s id=%d label=%s", t.Category, t.TypeId, *t.Label)
+	}
+
+	return fmt.Sprintf("category=%s id=%d", t.Category, t.TypeId)
+}
+
+// getUniqueIDs returns a slice of unsorted unique IDs from the given data.
+func getUniqueIDs(data *[]common.ReadResultRow) []string {
+	uniqueIDs := make(map[string]struct{})
+
+	for _, row := range *data {
+		uniqueIDs[row.Id] = struct{}{}
+	}
+
+	ids := make([]string, 0, len(uniqueIDs))
+
+	for id := range uniqueIDs {
+		ids = append(ids, id)
+	}
+
+	return ids
+}
+
+// fillAssociations fills the associations for the given object names and data.
+// Note that the data is modified in place.
+func (c *Connector) fillAssociations(
+	ctx context.Context,
+	fromObjName string,
+	data *[]common.ReadResultRow,
+	toAssociatedObjects []string,
+) error {
+	ids := getUniqueIDs(data)
+
+	for _, associatedObject := range toAssociatedObjects {
+		associations, err := c.getObjectAssociations(ctx, fromObjName, ids, associatedObject)
+		if err != nil {
+			return err
+		}
+
+		if len(associations) == 0 {
+			continue
+		}
+
+		for i, row := range *data {
+			if assocs, ok := associations[row.Id]; ok {
+				if (*data)[i].Associations == nil {
+					(*data)[i].Associations = make(map[string][]common.Association)
+				}
+
+				(*data)[i].Associations[associatedObject] = assocs
+			}
+		}
+	}
+
+	return nil
+}
+
+// getObjectAssociations returns the associations for the given object names and IDs. It returns
+// a mapping of object IDs to their associations.
+func (c *Connector) getObjectAssociations( //nolint:cyclop
+	ctx context.Context,
+	fromObject string,
+	fromIDs []string,
+	toObject string,
+) (map[string][]common.Association, error) {
+	if len(fromIDs) == 0 {
+		return map[string][]common.Association{}, nil
+	}
+
+	hsURL := c.BaseURL + "/" + fmt.Sprintf("crm/v4/associations/%s/%s/batch/read", fromObject, toObject)
+
+	var inputs assocInputs
+
+	for _, id := range fromIDs {
+		inputs.Inputs = append(inputs.Inputs, assocId{Id: id})
+	}
+
+	// Do one big batch request to get all associations.
+	// See https://developers.hubspot.com/docs/guides/api/crm/associations/associations-v4#retrieve-associated-records
+	rsp, err := c.Client.Post(ctx, hsURL, &inputs)
+	if err != nil {
+		var httpErr *common.HTTPStatusError
+
+		if errors.As(err, &httpErr) && httpErr.HTTPStatus == 404 {
+			logging.Logger(ctx).Warn("no associations found", "fromObject", fromObject, "toObject", toObject)
+
+			return map[string][]common.Association{}, nil
+		} else {
+			return nil, fmt.Errorf("error fetching HubSpot associations: %w", err)
+		}
+	}
+
+	output, err := common.UnmarshalJSON[assocOutput](rsp)
+	if err != nil {
+		return nil, err
+	}
+
+	out := make(map[string][]common.Association)
+
+	for _, result := range output.Results {
+		var assocs []common.Association
+
+		for _, assoc := range result.To {
+			for _, t := range assoc.AssociationTypes {
+				assocs = append(assocs, common.Association{
+					ObjectId:        strconv.FormatInt(assoc.ToObjectId, 10),
+					AssociationType: t.String(),
+				})
+			}
+		}
+
+		if len(assocs) > 0 {
+			out[result.From.Id] = assocs
+		}
+	}
+
+	return out, nil
+}
diff --git a/providers/hubspot/metadata.go b/providers/hubspot/metadata.go
index f0b9f19d..4864582c 100644
--- a/providers/hubspot/metadata.go
+++ b/providers/hubspot/metadata.go
@@ -2,14 +2,13 @@ package hubspot
 
 import (
 	"context"
-	"encoding/json"
 	"fmt"
 	"strconv"
 	"strings"
+	"time"
 
 	"github.com/amp-labs/connectors/common"
 	"github.com/amp-labs/connectors/common/logging"
-	"github.com/spyzhov/ajson"
 )
 
 type objectMetadataResult struct {
@@ -78,15 +77,6 @@ func (c *Connector) ListObjectMetadata( // nolint:cyclop,funlen
 	return objectsMap, nil
 }
 
-type describeObjectResponse struct {
-	Results []describeObjectResult `json:"results"`
-}
-
-type describeObjectResult struct {
-	Name  string `json:"name"`
-	Label string `json:"label"`
-}
-
 // describeObject returns object metadata for the given object name.
 func (c *Connector) describeObject(ctx context.Context, objectName string) (*common.ObjectMetadata, error) {
 	relativeURL := strings.Join([]string{"properties", objectName}, "/")
@@ -101,41 +91,14 @@ func (c *Connector) describeObject(ctx context.Context, objectName string) (*com
 		return nil, fmt.Errorf("error fetching HubSpot fields: %w", err)
 	}
 
-	body, ok := rsp.Body()
-	if !ok {
-		return nil, fmt.Errorf("cannot get HubSpot fields %w", common.ErrEmptyJSONHTTPResponse)
-	}
-
-	rawResponse, err := ajson.Marshal(body)
-	if err != nil {
-		return nil, fmt.Errorf("error marshalling object metadata response into byte array: %w", err)
-	}
-
-	resp := &describeObjectResponse{}
-
-	err = json.Unmarshal(rawResponse, resp)
+	resp, err := common.UnmarshalJSON[fieldDescriptionResponse](rsp)
 	if err != nil {
 		return nil, fmt.Errorf("error unmarshalling object metadata response into JSON: %w", err)
 	}
 
-	return &common.ObjectMetadata{
-		DisplayName: objectName,
-		FieldsMap:   makeFieldsMap(resp),
-	}, nil
-}
-
-// makeFieldsMap returns a map of field name to field label.
-func makeFieldsMap(data *describeObjectResponse) map[string]string {
-	fieldsMap := make(map[string]string)
-
-	for _, field := range data.Results {
-		fieldName := strings.ToLower(field.Name)
-
-		// Add entry to fieldsMap
-		fieldsMap[fieldName] = field.Label
-	}
-
-	return fieldsMap
+	return common.NewObjectMetadata(
+		objectName, resp.transformToFields(),
+	), nil
 }
 
 func (c *Connector) GetPostAuthInfo(
@@ -178,3 +141,118 @@ func (c *Connector) GetAccountInfo(ctx context.Context) (*AccountInfo, *common.J
 
 	return accountInfo, resp, nil
 }
+
+type fieldDescriptionResponse struct {
+	Results []fieldDescription `json:"results"`
+}
+
+type fieldDescription struct {
+	UpdatedAt            time.Time                 `json:"updatedAt"`
+	CreatedAt            time.Time                 `json:"createdAt"`
+	Name                 string                    `json:"name"`
+	Label                string                    `json:"label"`
+	Type                 string                    `json:"type"`
+	FieldType            string                    `json:"fieldType"`
+	Description          string                    `json:"description"`
+	GroupName            string                    `json:"groupName"`
+	Options              []fieldEnumerationOption  `json:"options"`
+	DisplayOrder         int                       `json:"displayOrder"`
+	Calculated           bool                      `json:"calculated"`
+	ExternalOptions      bool                      `json:"externalOptions"`
+	HasUniqueValue       bool                      `json:"hasUniqueValue"`
+	Hidden               bool                      `json:"hidden"`
+	HubspotDefined       bool                      `json:"hubspotDefined"`
+	ModificationMetadata fieldModificationMetadata `json:"modificationMetadata"`
+	FormField            bool                      `json:"formField"`
+	DataSensitivity      string                    `json:"dataSensitivity"`
+}
+
+type fieldEnumerationOption struct {
+	Label        string `json:"label"`
+	Value        string `json:"value"`
+	Description  string `json:"description"`
+	DisplayOrder int    `json:"displayOrder"`
+	Hidden       bool   `json:"hidden"`
+}
+
+type fieldModificationMetadata struct {
+	Archivable         bool `json:"archivable"`
+	ReadOnlyDefinition bool `json:"readOnlyDefinition"`
+	ReadOnlyValue      bool `json:"readOnlyValue"`
+}
+
+func (r fieldDescriptionResponse) transformToFields() map[string]common.FieldMetadata {
+	fieldsMap := make(map[string]common.FieldMetadata)
+
+	for _, field := range r.Results {
+		fieldName := strings.ToLower(field.Name)
+		fieldsMap[fieldName] = field.transformToFieldMetadata()
+	}
+
+	return fieldsMap
+}
+
+// transformToFieldMetadata converts Provider model of a field into Ampersand's common.FieldMetadata.
+// This normalizes provider response to the unified standard across all providers.
+func (o fieldDescription) transformToFieldMetadata() common.FieldMetadata {
+	var (
+		valueType common.ValueType
+		values    []common.FieldValue
+	)
+
+	// Based on type and field type properties from Hubspot object model map value to Ampersand value type.
+	switch o.Type {
+	case "string":
+		valueType = common.ValueTypeString
+	case "number":
+		valueType = common.ValueTypeFloat
+	case "bool":
+		valueType = common.ValueTypeBoolean
+	case "datetime":
+		valueType = common.ValueTypeDateTime
+	case "enumeration":
+		valueType, values = o.implyEnumerationType()
+		// Enumeration type means there are predefined field values.
+	default:
+		// ex: object_coordinates, phone_number
+		valueType = common.ValueTypeOther
+	}
+
+	return common.FieldMetadata{
+		DisplayName:  o.Label,
+		ValueType:    valueType,
+		ProviderType: o.Type + "." + o.FieldType,
+		ReadOnly:     o.ModificationMetadata.ReadOnlyValue,
+		Values:       values,
+	}
+}
+
+func (o fieldDescription) implyEnumerationType() (common.ValueType, []common.FieldValue) {
+	var values []common.FieldValue
+
+	if len(o.Options) != 0 {
+		// List of values is not nil, at least one option exists.
+		values = make([]common.FieldValue, len(o.Options))
+		for index, option := range o.Options {
+			values[index] = common.FieldValue{
+				Value:        option.Value,
+				DisplayValue: option.Label,
+			}
+		}
+	}
+
+	switch o.FieldType {
+	case "checkbox":
+		return common.ValueTypeMultiSelect, values
+	case "booleancheckbox":
+		// Boolean values are ignored.
+		return common.ValueTypeBoolean, nil
+	case "radio":
+		return common.ValueTypeSingleSelect, values
+	case "select":
+		return common.ValueTypeSingleSelect, values
+	default:
+		// ex: enumeration.calculation_equation
+		return common.ValueTypeOther, values
+	}
+}
diff --git a/providers/hubspot/parse.go b/providers/hubspot/parse.go
index 11ed9721..86648ffe 100644
--- a/providers/hubspot/parse.go
+++ b/providers/hubspot/parse.go
@@ -1,6 +1,8 @@
 package hubspot
 
 import (
+	"context"
+
 	"github.com/amp-labs/connectors/common"
 	"github.com/spyzhov/ajson"
 )
@@ -115,29 +117,41 @@ func getRecords(node *ajson.Node) ([]map[string]interface{}, error) {
 }
 
 // getMarshalledData accepts a list of records and returns a list of structured data ([]ReadResultRow).
-func getMarshalledData(records []map[string]interface{}, fields []string) ([]common.ReadResultRow, error) {
-	data := make([]common.ReadResultRow, len(records))
-
-	//nolint:varnamelen
-	for i, record := range records {
-		recordProperties, ok := record["properties"].(map[string]interface{})
-		if !ok {
-			return nil, ErrNotObject
+func (c *Connector) getMarshalledData(
+	ctx context.Context,
+	objName string,
+	associatedObjects []string,
+) func(records []map[string]interface{}, fields []string) ([]common.ReadResultRow, error) {
+	return func(records []map[string]interface{}, fields []string) ([]common.ReadResultRow, error) {
+		data := make([]common.ReadResultRow, len(records))
+
+		//nolint:varnamelen
+		for i, record := range records {
+			recordProperties, ok := record["properties"].(map[string]interface{})
+			if !ok {
+				return nil, ErrNotObject
+			}
+
+			id, ok := record["id"].(string)
+			if !ok {
+				return nil, errMissingId
+			}
+
+			data[i] = common.ReadResultRow{
+				Fields: common.ExtractLowercaseFieldsFromRaw(fields, recordProperties),
+				Raw:    record,
+				Id:     id,
+			}
 		}
 
-		id, ok := record["id"].(string)
-		if !ok {
-			return nil, errMissingId
+		if len(associatedObjects) > 0 {
+			if err := c.fillAssociations(ctx, objName, &data, associatedObjects); err != nil {
+				return nil, err
+			}
 		}
 
-		data[i] = common.ReadResultRow{
-			Fields: common.ExtractLowercaseFieldsFromRaw(fields, recordProperties),
-			Raw:    record,
-			Id:     id,
-		}
+		return data, nil
 	}
-
-	return data, nil
 }
 
 // GetResultId returns the id of a hubspot result row.
diff --git a/providers/hubspot/read.go b/providers/hubspot/read.go
index 31224ef5..953c39b8 100644
--- a/providers/hubspot/read.go
+++ b/providers/hubspot/read.go
@@ -45,8 +45,9 @@ func (c *Connector) Read(ctx context.Context, config common.ReadParams) (*common
 			SortBy: []SortBy{
 				BuildSort(ObjectFieldHsObjectId, SortDirectionAsc),
 			},
-			NextPage: config.NextPage,
-			Fields:   config.Fields,
+			NextPage:          config.NextPage,
+			Fields:            config.Fields,
+			AssociatedObjects: config.AssociatedObjects,
 		}
 
 		return c.Search(ctx, searchParams)
@@ -79,7 +80,7 @@ func (c *Connector) Read(ctx context.Context, config common.ReadParams) (*common
 		rsp,
 		getRecords,
 		getNextRecordsURL,
-		getMarshalledData,
+		c.getMarshalledData(ctx, config.ObjectName, config.AssociatedObjects),
 		config.Fields,
 	)
 }
diff --git a/providers/hubspot/record.go b/providers/hubspot/record.go
index 29d06548..0f59caf2 100644
--- a/providers/hubspot/record.go
+++ b/providers/hubspot/record.go
@@ -87,7 +87,7 @@ func (c *Connector) GetRecordsWithIds(
 
 	if len(fields) != 0 {
 		// If fields are specified, extract only those fields from the record.
-		return getMarshalledData(records, fields)
+		return c.getMarshalledData(ctx, objectName, associations)(records, fields)
 	}
 
 	data := make([]common.ReadResultRow, len(records))
diff --git a/providers/hubspot/search.go b/providers/hubspot/search.go
index 40869459..574bacf4 100644
--- a/providers/hubspot/search.go
+++ b/providers/hubspot/search.go
@@ -43,7 +43,7 @@ func (c *Connector) Search(ctx context.Context, config SearchParams) (*common.Re
 		rsp,
 		getRecords,
 		getNextRecordsAfter,
-		getMarshalledData,
+		c.getMarshalledData(ctx, config.ObjectName, config.AssociatedObjects),
 		config.Fields,
 	)
 }
diff --git a/providers/hubspot/types.go b/providers/hubspot/types.go
index a425ac3f..5c02eaba 100644
--- a/providers/hubspot/types.go
+++ b/providers/hubspot/types.go
@@ -16,6 +16,8 @@ type SearchParams struct {
 	FilterGroups []FilterGroup // optional
 	// Fields is the list of fields to return in the result.
 	Fields datautils.Set[string] // optional
+	// AssociatedObjects is a list of associated objects to fetch along with the main object.
+	AssociatedObjects []string // optional
 }
 
 func (p SearchParams) ValidateParams() error {
diff --git a/providers/iterable.go b/providers/iterable.go
index 9f90c624..d71c0d08 100644
--- a/providers/iterable.go
+++ b/providers/iterable.go
@@ -23,9 +23,9 @@ func init() {
 				Delete: false,
 			},
 			Proxy:     true,
-			Read:      false,
+			Read:      true,
 			Subscribe: false,
-			Write:     false,
+			Write:     true,
 		},
 		Media: &Media{
 			DarkMode: &MediaTypeDarkMode{
diff --git a/providers/iterable/README.md b/providers/iterable/README.md
new file mode 100644
index 00000000..369e5a8e
--- /dev/null
+++ b/providers/iterable/README.md
@@ -0,0 +1,95 @@
+# Description
+
+This is an exhaustive list of API endpoints which are excluded from Write/Delete connector implementation and therefore are available only via proxy.
+
+# Excluded Endpoints
+
+**Authentication**
+- [/api/auth/jwts/invalidate](https://api.iterable.com/api/docs#users_Invalidate_JWT) - Invalidate all JWTs issued for a user
+
+**Campaigns**
+- [/api/campaigns/abort](https://api.iterable.com/api/docs#campaigns_abort_campaign) - Abort Campaign
+- [/api/campaigns/activateTriggered](https://api.iterable.com/api/docs#campaigns_activate_triggered_campaign) - Activate a triggered campaign
+- [/api/campaigns/archive](https://api.iterable.com/api/docs#campaigns_archive_campaigns) - Archive campaigns
+- [/api/campaigns/cancel](https://api.iterable.com/api/docs#campaigns_cancel_campaign) - Cancel a scheduled or recurring campaign
+- [/api/campaigns/deactivateTriggered](https://api.iterable.com/api/docs#campaigns_Deactivate_triggered_campaign) - Deactivate a triggered campaign
+- [/api/campaigns/trigger](https://api.iterable.com/api/docs#campaigns_trigger_campaign) - Trigger a campaign
+
+**Commerce**
+- [/api/commerce/trackPurchase](https://api.iterable.com/api/docs#commerce_trackPurchase) - Track a purchase
+- [/api/commerce/updateCart](https://api.iterable.com/api/docs#commerce_updateCart) - Update a user's shopping cart items
+
+**Emails**
+- [/api/email/cancel](https://api.iterable.com/api/docs#email_cancel) - Cancel an email to a user
+- [/api/email/target](https://api.iterable.com/api/docs#email_target) - Send an email to an email address
+
+**Message Events**
+- [/api/embedded-messaging/events/click](https://api.iterable.com/api/docs#events_embedded_track_click) - Track an embedded message click
+- [/api/embedded-messaging/events/received](https://api.iterable.com/api/docs#events_embedded_track_received) - Track an embedded message received event
+- [/api/embedded-messaging/events/session](https://api.iterable.com/api/docs#events_embedded_track_impression) - Track an embedded message session and related impressions
+
+**Events**
+- [/api/events/inAppConsume](https://api.iterable.com/api/docs#events_inAppConsume) - Consume or delete an in-app message
+- [/api/events/trackBulk](https://api.iterable.com/api/docs#events_trackBulk) - Bulk track events
+- [/api/events/trackInAppClick](https://api.iterable.com/api/docs#events_trackInAppClick) - Track an in-app message click
+- [/api/events/trackInAppClose](https://api.iterable.com/api/docs#events_trackInAppClose) - Track the closing of an in-app message
+- [/api/events/trackInAppDelivery](https://api.iterable.com/api/docs#events_trackInAppDelivery) - Track the delivery of an in-app message
+- [/api/events/trackInAppOpen](https://api.iterable.com/api/docs#events_trackInAppOpen) - Track an in-app message open
+- [/api/events/trackPushOpen](https://api.iterable.com/api/docs#events_trackPushOpen) - Track a mobile push open
+- [/api/events/trackWebPushClick](https://api.iterable.com/api/docs#events_trackWebPushClick) - Track a web push click
+- [/api/events/track](https://api.iterable.com/api/docs#events_track) - Track an event
+- [/api/export/start](https://api.iterable.com/api/docs#export_startExport) - Start export
+
+**In-App Notifications**
+- [/api/inApp/cancel](https://api.iterable.com/api/docs#In-app_cancel) - Cancel a scheduled in-app message
+- [/api/inApp/target](https://api.iterable.com/api/docs#In-app_target) - Send an in-app notification to a user
+
+**Lists**
+- [/api/lists/subscribe](https://api.iterable.com/api/docs#lists_subscribe) - Add subscribers to list
+- [/api/lists/unsubscribe](https://api.iterable.com/api/docs#lists_unsubscribe) - Remove users from a list
+- [/api/lists](https://api.iterable.com/api/docs#lists_create) - Create a static list
+
+**Push Notifications**
+- [/api/push/cancel](https://api.iterable.com/api/docs#push_cancel) - Cancel a push notification to a user
+- [/api/push/target](https://api.iterable.com/api/docs#push_target) - Send push notification to user
+
+**SMS**
+- [/api/sms/cancel](https://api.iterable.com/api/docs#SMS_cancel) - Cancel an SMS to a user
+- [/api/sms/target](https://api.iterable.com/api/docs#SMS_target) - Send SMS notification to user
+
+**Subscription**
+- [/api/subscriptions/subscribeToDoubleOptIn](https://api.iterable.com/api/docs#subscriptions_subscribeSingleUserToDoubleOptIn) - Trigger a double opt-in subscription flow
+
+**Templates**
+- [/api/templates/bulkDelete](https://api.iterable.com/api/docs#templates_bulk_delete_templates) - Bulk delete templates
+
+**Users**
+- [/api/users/bulkUpdateSubscriptions](https://api.iterable.com/api/docs#users_bulkUpdateSubscriptions) - Bulk update user subscriptions
+- [/api/users/bulkUpdate](https://api.iterable.com/api/docs#users_bulkUpdateUser) - Bulk update user data
+- [/api/users/disableDevice](https://api.iterable.com/api/docs#users_disableDevice) - Disable pushes to a mobile device
+- [/api/users/forget](https://api.iterable.com/api/docs#users_forget) - Forget a user in compliance with GDPR
+- [/api/users/registerBrowserToken](https://api.iterable.com/api/docs#users_registerBrowserToken) - Register a browser token for web push
+- [/api/users/registerDeviceToken](https://api.iterable.com/api/docs#users_registerDeviceToken) - Register a device token for push
+- [/api/users/unforget](https://api.iterable.com/api/docs#users_unforget) - Unforget a user in compliance with GDPR
+- [/api/users/updateEmail](https://api.iterable.com/api/docs#users_updateEmail) - Update user email
+- [/api/users/updateSubscriptions](https://api.iterable.com/api/docs#users_updateSubscriptions) - Update user subscriptions
+
+**Verifications**
+- [/api/verify/sms/begin](https://api.iterable.com/api/docs#Verify_beginSmsVerification) - Begin SMS Verification
+- [/api/verify/sms/check](https://api.iterable.com/api/docs#Verify_checkSmsVerification) - Check SMS Verification Code
+
+**Web Push Notification**
+- [/api/webPush/cancel](https://api.iterable.com/api/docs#webPush_cancel) - Cancel a web push notification to a user
+- [/api/webPush/target](https://api.iterable.com/api/docs#webPush_target) - Send web push notification to user
+
+**Workflow**
+- [/api/workflows/triggerWorkflow](https://api.iterable.com/api/docs#workflows_triggerWorkflow) - Trigger a journey (workflow)
+
+
+Endpoints that are performing alike operations are excluded as well. 
+In other words they are covered by related endpoint. Excluded similiar endpoints are as follows:
+- [/api/templates/email/update](https://api.iterable.com/api/docs#templates_updateEmailTemplate) - Update email template
+- [/api/templates/inapp/update](https://api.iterable.com/api/docs#templates_updateInAppTemplate) - Update in-app template
+- [/api/templates/push/update](https://api.iterable.com/api/docs#templates_updatePushTemplate) - Update push template
+- [/api/templates/sms/update](https://api.iterable.com/api/docs#templates_updateSMSTemplate) - Update SMS template
+- [/api/users/{email}](https://api.iterable.com/api/docs#users_delete_0) - Delete a user by email
diff --git a/providers/iterable/connector.go b/providers/iterable/connector.go
index c6892abe..0033b225 100644
--- a/providers/iterable/connector.go
+++ b/providers/iterable/connector.go
@@ -53,6 +53,18 @@ func (c *Connector) getReadURL(objectName string) (*urlbuilder.URL, error) {
 	return urlbuilder.New(c.BaseURL, path)
 }
 
+func (c *Connector) getWriteURL(objectName string) (*urlbuilder.URL, error) {
+	path := supportedObjectsByWrite[objectName]
+
+	return urlbuilder.New(c.BaseURL, path)
+}
+
+func (c *Connector) getDeleteURL(objectName, recordID string) (*urlbuilder.URL, error) {
+	path := supportedObjectsByDelete[objectName]
+
+	return urlbuilder.New(c.BaseURL, path, recordID)
+}
+
 func (c *Connector) setBaseURL(newURL string) {
 	c.BaseURL = newURL
 	c.Client.HTTPClient.Base = newURL
diff --git a/providers/iterable/delete.go b/providers/iterable/delete.go
new file mode 100644
index 00000000..cae48a40
--- /dev/null
+++ b/providers/iterable/delete.go
@@ -0,0 +1,34 @@
+package iterable
+
+import (
+	"context"
+
+	"github.com/amp-labs/connectors/common"
+)
+
+func (c *Connector) Delete(ctx context.Context, config common.DeleteParams) (*common.DeleteResult, error) {
+	if err := config.ValidateParams(); err != nil {
+		return nil, err
+	}
+
+	if !supportedObjectsByDelete.Has(config.ObjectName) {
+		// Removing tags is the only to be supported at this time.
+		// https://developer.instantly.ai/tags/delete-a-tag
+		return nil, common.ErrOperationNotSupportedForObject
+	}
+
+	url, err := c.getDeleteURL(config.ObjectName, config.RecordId)
+	if err != nil {
+		return nil, err
+	}
+
+	// 200 OK is expected
+	_, err = c.Client.Delete(ctx, url.String())
+	if err != nil {
+		return nil, err
+	}
+
+	return &common.DeleteResult{
+		Success: true,
+	}, nil
+}
diff --git a/providers/iterable/objectNames.go b/providers/iterable/objectNames.go
index 0b272c4f..93051311 100644
--- a/providers/iterable/objectNames.go
+++ b/providers/iterable/objectNames.go
@@ -16,9 +16,52 @@ var paginatedObjects = datautils.NewSet( //nolint:gochecknoglobals
 	objectNameJourneys,
 )
 
-var incrementalReadObjects = datautils.NewSet( //nolint:gochecknoglobals
-	objectNameTemplates,
+// While reading is done against one object, the writing occurs by template type.
+var templateWriteObjects = datautils.NewSet( //nolint:gochecknoglobals
+	"templatesEmail",
+	"templatesInApp",
+	"templatesPush",
+	"templatesSMS",
 )
 
 // Supported object names can be found under schemas.json.
 var supportedObjectsByRead = metadata.Schemas.ObjectNames() //nolint:gochecknoglobals
+
+var supportedObjectsByWrite = datautils.Map[string, string]{ //nolint:gochecknoglobals
+	// https://api.iterable.com/api/docs#campaigns_create_campaign
+	"campaigns": "/api/campaigns/create",
+	// https://api.iterable.com/api/docs#catalogs_createCatalog
+	// This endpoint doesn't use payload! Catalog name comes from the path {catalogName}.
+	objectNameCatalogs: "/api/catalogs",
+	// https://api.iterable.com/api/docs#lists_create
+	"lists": "/api/lists",
+	// https://api.iterable.com/api/docs#users_updateUser
+	"users": "/api/users/update", // Update or create user.
+	// https://api.iterable.com/api/docs#webhooks_updateWebhook
+	"webhooks": "/api/webhooks", // Update webhook
+
+	//
+	// Template objects.
+	//
+	// https://api.iterable.com/api/docs#templates_upsertEmailTemplate
+	"templatesEmail": "/api/templates/email/upsert",
+	// https://api.iterable.com/api/docs#templates_upsertInAppTemplate
+	"templatesInApp": "/api/templates/inapp/upsert",
+	// https://api.iterable.com/api/docs#templates_upsertPushTemplate
+	"templatesPush": "/api/templates/push/upsert",
+	// https://api.iterable.com/api/docs#templates_upsertSMSTemplate
+	"templatesSMS": "/api/templates/sms/upsert",
+}
+
+var supportedObjectsByDelete = datautils.Map[string, string]{ //nolint:gochecknoglobals
+	// https://api.iterable.com/api/docs#catalogs_deleteCatalog
+	objectNameCatalogs: "/api/catalogs", // by catalogName
+	// https://api.iterable.com/api/docs#export_cancelExport
+	"exports": "/api/export", // by jobId
+	// https://api.iterable.com/api/docs#lists_delete
+	"lists": "/api/lists", // by listId
+	// https://api.iterable.com/api/docs#metadata_delete
+	"metadata": "/api/metadata", // by table
+	// https://api.iterable.com/api/docs#users_delete
+	"users": "/api/users/byUserId", // by userId
+}
diff --git a/providers/iterable/read.go b/providers/iterable/read.go
index 52a0279c..d0e8407c 100644
--- a/providers/iterable/read.go
+++ b/providers/iterable/read.go
@@ -6,7 +6,6 @@ import (
 
 	"github.com/amp-labs/connectors/common"
 	"github.com/amp-labs/connectors/common/urlbuilder"
-	"github.com/amp-labs/connectors/internal/datautils"
 )
 
 func (c *Connector) Read(ctx context.Context, config common.ReadParams) (*common.ReadResult, error) {
@@ -52,10 +51,5 @@ func (c *Connector) buildReadURL(config common.ReadParams) (*urlbuilder.URL, err
 		url.WithQueryParam("pageSize", strconv.Itoa(DefaultPageSize))
 	}
 
-	if incrementalReadObjects.Has(config.ObjectName) && !config.Since.IsZero() {
-		sinceValue := datautils.Time.FormatRFC3339inUTC(config.Since)
-		url.WithQueryParam("startDateTime", sinceValue)
-	}
-
 	return url, nil
 }
diff --git a/providers/iterable/write.go b/providers/iterable/write.go
new file mode 100644
index 00000000..58ce8d6c
--- /dev/null
+++ b/providers/iterable/write.go
@@ -0,0 +1,164 @@
+package iterable
+
+import (
+	"context"
+	"errors"
+	"strconv"
+	"strings"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/jsonquery"
+	"github.com/amp-labs/connectors/common/urlbuilder"
+	"github.com/amp-labs/connectors/internal/datautils"
+	"github.com/spyzhov/ajson"
+)
+
+var ErrCatalogCreate = errors.New("payload must have string 'name' to create a catalog")
+
+func (c *Connector) Write(
+	ctx context.Context, config common.WriteParams,
+) (*common.WriteResult, error) {
+	if err := config.ValidateParams(); err != nil {
+		return nil, err
+	}
+
+	if !supportedObjectsByWrite.Has(config.ObjectName) {
+		return nil, common.ErrOperationNotSupportedForObject
+	}
+
+	// Object name will be used to complete further URL construction below.
+	url, err := c.getWriteURL(config.ObjectName)
+	if err != nil {
+		return nil, err
+	}
+
+	if config.ObjectName == objectNameCatalogs {
+		if err = createCatalog(config, url); err != nil {
+			return nil, err
+		}
+	}
+
+	res, err := c.Client.Post(ctx, url.String(), config.RecordData)
+	if err != nil {
+		return nil, err
+	}
+
+	body, ok := res.Body()
+	if !ok {
+		// it is unlikely to have no payload
+		return &common.WriteResult{
+			Success: true,
+		}, nil
+	}
+
+	// write response was with payload
+	return constructWriteResult(body, config.ObjectName)
+}
+
+// This is the only endpoint that doesn't use payload.
+// New catalog requires a name which is passed as path parameter in URL.
+// However, the write connector will accept payload of shape:
+//
+//	{
+//		"name": "catalogName"
+//	}
+func createCatalog(config common.WriteParams, url *urlbuilder.URL) error {
+	payload, isJSON := config.RecordData.(map[string]any)
+	if !isJSON {
+		return ErrCatalogCreate
+	}
+
+	name, found := payload["name"]
+	if !found {
+		return ErrCatalogCreate
+	}
+
+	catalogName, isString := name.(string)
+	if !isString {
+		return ErrCatalogCreate
+	}
+
+	url.AddPath(catalogName)
+
+	return nil
+}
+
+type path struct {
+	id   string
+	zoom []string
+}
+
+func newPath(id string, zoom ...string) *path {
+	return &path{
+		id:   id,
+		zoom: zoom,
+	}
+}
+
+var recordIDPaths = datautils.Map[string, *path]{ // nolint:gochecknoglobals
+	"campaigns":      newPath("campaignId"),
+	"catalogs":       newPath("id", "params"),
+	"lists":          newPath("listId"),
+	"templatesEmail": nil,
+	"templatesInApp": nil,
+	"templatesPush":  nil,
+	"templatesSMS":   nil,
+	"users":          nil,
+	"webhooks":       newPath("id"),
+}
+
+func constructWriteResult(body *ajson.Node, objectName string) (*common.WriteResult, error) {
+	recordID, err := locateWriteRecordID(body, objectName)
+	if err != nil {
+		return nil, err
+	}
+
+	if templateWriteObjects.Has(objectName) {
+		// Template objects return ID as part of the message.
+		// Other objects, return identifier using a dedicated property.
+		// Sadly, string processing is needed to extract ID.
+		recordID, err = extractTemplateWriteRecordID(body)
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	return &common.WriteResult{
+		Success:  true,
+		RecordId: recordID,
+		Errors:   nil,
+		Data:     nil,
+	}, nil
+}
+
+func locateWriteRecordID(body *ajson.Node, objectName string) (string, error) {
+	recordIDLocation := recordIDPaths[objectName]
+	if recordIDLocation == nil {
+		return "", nil
+	}
+
+	// ID is integer that is always stored under different field name.
+	intIdentifier, err := jsonquery.New(body, recordIDLocation.zoom...).Integer(recordIDLocation.id, false)
+	if err != nil {
+		return "", err
+	}
+
+	return strconv.FormatInt(*intIdentifier, 10), nil
+}
+
+func extractTemplateWriteRecordID(body *ajson.Node) (string, error) {
+	message, err := jsonquery.New(body).Str("msg", true)
+	if err != nil {
+		return "", err
+	}
+
+	// Ex:
+	//	Input: "Upserted 1 templates with IDs: 15939824"
+	//	Output: "15939824"
+	parts := strings.Split(*message, "IDs: ")
+	if len(parts) != 2 { // nolint:mnd
+		return "", nil
+	}
+
+	return parts[1], nil
+}


Test patch:
diff --git a/providers/hubspot/metadata_test.go b/providers/hubspot/metadata_test.go
new file mode 100644
index 00000000..7ef18f78
--- /dev/null
+++ b/providers/hubspot/metadata_test.go
@@ -0,0 +1,200 @@
+package hubspot
+
+import (
+	"net/http"
+	"testing"
+
+	"github.com/amp-labs/connectors"
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/test/utils/mockutils/mockcond"
+	"github.com/amp-labs/connectors/test/utils/mockutils/mockserver"
+	"github.com/amp-labs/connectors/test/utils/testroutines"
+	"github.com/amp-labs/connectors/test/utils/testutils"
+)
+
+func TestListObjectMetadata(t *testing.T) { // nolint:funlen,gocognit,cyclop
+	t.Parallel()
+
+	metadataContactsProperties := testutils.DataFromFile(t, "metadata-contacts-properties-sampled.json")
+
+	tests := []testroutines.Metadata{
+		{
+			Name:         "At least one object name must be queried",
+			Input:        nil,
+			Server:       mockserver.Dummy(),
+			ExpectedErrs: []error{common.ErrMissingObjects},
+		},
+		{
+			Name:  "Successfully describe contacts",
+			Input: []string{"contacts"},
+			Server: mockserver.Conditional{
+				Setup: mockserver.ContentJSON(),
+				If:    mockcond.PathSuffix("/crm/v3/properties/contacts"),
+				Then:  mockserver.Response(http.StatusOK, metadataContactsProperties),
+			}.Server(),
+			Comparator: testroutines.ComparatorSubsetMetadata,
+			Expected: &common.ListObjectMetadataResult{
+				Result: map[string]common.ObjectMetadata{
+					"contacts": {
+						DisplayName: "contacts",
+						Fields: map[string]common.FieldMetadata{
+							// String
+							"address": {
+								DisplayName:  "Street Address",
+								ValueType:    "string",
+								ProviderType: "string.text",
+								ReadOnly:     false,
+								Values:       nil,
+							},
+							"mobilephone": {
+								DisplayName:  "Mobile Phone Number",
+								ValueType:    "string",
+								ProviderType: "string.phonenumber",
+								ReadOnly:     false,
+								Values:       nil,
+							},
+
+							// Boolean.
+							"hs_contact_enrichment_opt_out": {
+								DisplayName:  "Enrichment opt out",
+								ValueType:    "boolean",
+								ProviderType: "bool.booleancheckbox",
+								ReadOnly:     true,
+								Values:       nil,
+							},
+							"autogen": {
+								DisplayName:  "autogen",
+								ValueType:    "boolean",
+								ProviderType: "enumeration.booleancheckbox",
+								ReadOnly:     false,
+								Values:       nil,
+							},
+
+							// Float.
+							"associatedcompanyid": {
+								DisplayName:  "Primary Associated Company ID",
+								ValueType:    "float",
+								ProviderType: "number.number",
+								ReadOnly:     false,
+								Values:       nil,
+							},
+							"hubspotscore": {
+								DisplayName:  "HubSpot Score",
+								ValueType:    "float",
+								ProviderType: "number.calculation_score",
+								ReadOnly:     true,
+								Values:       nil,
+							},
+							"hs_associated_target_accounts": {
+								DisplayName:  "Associated Target Accounts",
+								ValueType:    "float",
+								ProviderType: "number.calculation_rollup",
+								ReadOnly:     true,
+								Values:       nil,
+							},
+
+							// Single/Multi Select.
+							"hs_content_membership_status": {
+								DisplayName:  "Status",
+								ValueType:    "singleSelect",
+								ProviderType: "enumeration.select",
+								ReadOnly:     false,
+								Values: []common.FieldValue{{
+									Value:        "active",
+									DisplayValue: "Active",
+								}, {
+									Value:        "inactive",
+									DisplayValue: "Inactive",
+								}},
+							},
+							"hs_predictivecontactscorebucket": {
+								DisplayName:  "Lead Rating",
+								ValueType:    "singleSelect",
+								ProviderType: "enumeration.radio",
+								ReadOnly:     true,
+								Values: []common.FieldValue{{
+									Value:        "bucket_1",
+									DisplayValue: "1 Star",
+								}, {
+									Value:        "bucket_2",
+									DisplayValue: "2 Stars",
+								}, {
+									Value:        "bucket_3",
+									DisplayValue: "3 Stars",
+								}, {
+									Value:        "bucket_4",
+									DisplayValue: "4 Stars",
+								}},
+							},
+							"hs_all_assigned_business_unit_ids": {
+								DisplayName:  "Business units",
+								ValueType:    "multiSelect",
+								ProviderType: "enumeration.checkbox",
+								ReadOnly:     false,
+								Values:       nil,
+							},
+
+							// Datetime.
+							"hs_first_subscription_create_date": {
+								DisplayName:  "First subscription create date",
+								ValueType:    "datetime",
+								ProviderType: "datetime.calculation_rollup",
+								ReadOnly:     true,
+								Values:       nil,
+							},
+							"hs_date_entered_customer": {
+								DisplayName:  "Date entered 'Customer (Lifecycle Stage Pipeline)'",
+								ValueType:    "datetime",
+								ProviderType: "datetime.calculation_read_time",
+								ReadOnly:     true,
+								Values:       nil,
+							},
+
+							// Others.
+							"hs_notes_last_activity": {
+								DisplayName:  "Last Activity",
+								ValueType:    "other",
+								ProviderType: "object_coordinates.text",
+								ReadOnly:     true,
+								Values:       nil,
+							},
+						},
+						FieldsMap: map[string]string{
+							"address":                         "Street Address",
+							"associatedcompanyid":             "Primary Associated Company ID",
+							"hs_predictivecontactscorebucket": "Lead Rating",
+						},
+					},
+				},
+				Errors: nil,
+			},
+			ExpectedErrs: nil,
+		},
+	}
+
+	for _, tt := range tests {
+		// nolint:varnamelen
+		t.Run(tt.Name, func(t *testing.T) {
+			t.Parallel()
+
+			tt.Run(t, func() (connectors.ObjectMetadataConnector, error) {
+				return constructTestConnector(tt.Server.URL)
+			})
+		})
+	}
+}
+
+func constructTestConnector(serverURL string) (*Connector, error) {
+	connector, err := NewConnector(
+		WithAuthenticatedClient(http.DefaultClient),
+		WithModule(ModuleCRM),
+	)
+	if err != nil {
+		return nil, err
+	}
+
+	// for testing we want to redirect calls to our mock server
+	connector.setBaseURL(serverURL)
+
+	return connector, nil
+}
diff --git a/providers/hubspot/test/metadata-contacts-properties-sampled.json b/providers/hubspot/test/metadata-contacts-properties-sampled.json
new file mode 100644
index 00000000..68d2fdf5
--- /dev/null
+++ b/providers/hubspot/test/metadata-contacts-properties-sampled.json
@@ -0,0 +1,391 @@
+{
+  "results": [
+    {
+      "updatedAt": "2024-09-12T14:22:56.844Z",
+      "createdAt": "2020-06-30T15:57:37.277Z",
+      "name": "address",
+      "label": "Street Address",
+      "type": "string",
+      "fieldType": "text",
+      "description": "Contact's street address, including apartment or unit number.",
+      "groupName": "contactinformation",
+      "options": [],
+      "displayOrder": 6,
+      "calculated": false,
+      "externalOptions": false,
+      "hasUniqueValue": false,
+      "hidden": false,
+      "hubspotDefined": true,
+      "modificationMetadata": {
+        "archivable": true,
+        "readOnlyDefinition": true,
+        "readOnlyValue": false
+      },
+      "formField": true,
+      "dataSensitivity": "non_sensitive"
+    },
+    {
+      "updatedAt": "2024-09-12T14:20:30.071Z",
+      "createdAt": "2019-08-06T02:41:08.304Z",
+      "name": "mobilephone",
+      "label": "Mobile Phone Number",
+      "type": "string",
+      "fieldType": "phonenumber",
+      "description": "A contact's mobile phone number",
+      "groupName": "contactinformation",
+      "options": [],
+      "displayOrder": 4,
+      "calculated": false,
+      "externalOptions": false,
+      "hasUniqueValue": false,
+      "hidden": false,
+      "hubspotDefined": true,
+      "modificationMetadata": {
+        "archivable": true,
+        "readOnlyDefinition": true,
+        "readOnlyValue": false
+      },
+      "formField": true,
+      "dataSensitivity": "non_sensitive"
+    },
+    {
+      "updatedAt": "2024-09-05T17:14:04.747Z",
+      "createdAt": "2019-08-06T02:41:09.377Z",
+      "name": "associatedcompanyid",
+      "label": "Primary Associated Company ID",
+      "type": "number",
+      "fieldType": "number",
+      "description": "HubSpot defined ID of a contact's primary associated company in HubSpot.",
+      "groupName": "contactinformation",
+      "options": [],
+      "referencedObjectType": "COMPANY",
+      "displayOrder": 24,
+      "calculated": false,
+      "externalOptions": true,
+      "hasUniqueValue": false,
+      "hidden": true,
+      "hubspotDefined": true,
+      "modificationMetadata": {
+        "archivable": true,
+        "readOnlyDefinition": true,
+        "readOnlyValue": false
+      },
+      "formField": false,
+      "dataSensitivity": "non_sensitive"
+    },
+    {
+      "updatedAt": "2024-07-11T18:58:25.585Z",
+      "createdAt": "2024-07-11T18:58:25.585Z",
+      "name": "hs_associated_target_accounts",
+      "label": "Associated Target Accounts",
+      "type": "number",
+      "fieldType": "calculation_rollup",
+      "description": "The number of target accounts associated with this contact",
+      "groupName": "contactinformation",
+      "options": [],
+      "displayOrder": -1,
+      "calculated": false,
+      "externalOptions": false,
+      "hasUniqueValue": false,
+      "hidden": true,
+      "hubspotDefined": true,
+      "modificationMetadata": {
+        "archivable": true,
+        "readOnlyDefinition": false,
+        "readOnlyValue": true
+      },
+      "formField": false,
+      "dataSensitivity": "non_sensitive"
+    },
+    {
+      "updatedAt": "2023-05-01T20:02:33.973Z",
+      "createdAt": "2021-08-25T18:25:22.818Z",
+      "name": "hs_date_entered_customer",
+      "label": "Date entered 'Customer (Lifecycle Stage Pipeline)'",
+      "type": "datetime",
+      "fieldType": "calculation_read_time",
+      "description": "The date and time when the contact entered the 'Customer' stage, 'Lifecycle Stage Pipeline' pipeline",
+      "groupName": "contactinformation",
+      "options": [],
+      "displayOrder": -1,
+      "calculated": false,
+      "externalOptions": false,
+      "hasUniqueValue": false,
+      "hidden": true,
+      "hubspotDefined": true,
+      "modificationMetadata": {
+        "archivable": true,
+        "readOnlyDefinition": true,
+        "readOnlyValue": true
+      },
+      "formField": false,
+      "dataSensitivity": "non_sensitive"
+    },
+    {
+      "updatedAt": "2024-11-12T18:28:19.101Z",
+      "createdAt": "2020-06-30T15:57:36.876Z",
+      "name": "hubspotscore",
+      "label": "HubSpot Score",
+      "type": "number",
+      "fieldType": "calculation_score",
+      "description": "The number that shows qualification of contacts to sales readiness. It can be set in HubSpot's Lead Scoring app.",
+      "groupName": "sales_properties",
+      "options": [],
+      "displayOrder": 18,
+      "calculated": false,
+      "externalOptions": false,
+      "hasUniqueValue": false,
+      "hidden": false,
+      "hubspotDefined": true,
+      "modificationMetadata": {
+        "archivable": true,
+        "readOnlyDefinition": true,
+        "readOnlyValue": true
+      },
+      "formField": false,
+      "dataSensitivity": "non_sensitive"
+    },
+    {
+      "updatedAt": "2024-01-08T23:09:00.469Z",
+      "createdAt": "2024-01-08T23:09:00.469Z",
+      "name": "autogen",
+      "label": "autogen",
+      "type": "enumeration",
+      "fieldType": "booleancheckbox",
+      "description": "True if this contact was generated programatically for testing purposes",
+      "groupName": "contactinformation",
+      "options": [
+        {
+          "label": "Yes",
+          "value": "true",
+          "displayOrder": 0,
+          "hidden": false
+        },
+        {
+          "label": "No",
+          "value": "false",
+          "displayOrder": 1,
+          "hidden": false
+        }
+      ],
+      "createdUserId": "61526374",
+      "updatedUserId": "61526374",
+      "displayOrder": -1,
+      "calculated": false,
+      "externalOptions": false,
+      "archived": false,
+      "hasUniqueValue": false,
+      "hidden": false,
+      "showCurrencySymbol": false,
+      "modificationMetadata": {
+        "archivable": true,
+        "readOnlyDefinition": false,
+        "readOnlyValue": false
+      },
+      "formField": true,
+      "dataSensitivity": "non_sensitive"
+    },
+    {
+      "updatedAt": "2024-09-09T18:57:29.341Z",
+      "createdAt": "2024-05-29T12:52:00.227Z",
+      "name": "hs_contact_enrichment_opt_out",
+      "label": "Enrichment opt out",
+      "type": "bool",
+      "fieldType": "booleancheckbox",
+      "description": "",
+      "groupName": "contactinformation",
+      "options": [
+        {
+          "label": "Yes",
+          "value": "true",
+          "displayOrder": 0,
+          "hidden": false
+        },
+        {
+          "label": "No",
+          "value": "false",
+          "displayOrder": 1,
+          "hidden": false
+        }
+      ],
+      "displayOrder": -1,
+      "calculated": false,
+      "externalOptions": false,
+      "hasUniqueValue": false,
+      "hidden": false,
+      "hubspotDefined": true,
+      "modificationMetadata": {
+        "archivable": true,
+        "readOnlyDefinition": true,
+        "readOnlyValue": true
+      },
+      "formField": false,
+      "dataSensitivity": "non_sensitive"
+    },
+    {
+      "updatedAt": "2024-09-12T14:22:42.741Z",
+      "createdAt": "2020-06-30T15:57:37.144Z",
+      "name": "hs_content_membership_status",
+      "label": "Status",
+      "type": "enumeration",
+      "fieldType": "select",
+      "description": "Status of the contact's content membership.",
+      "groupName": "contact_activity",
+      "options": [
+        {
+          "label": "Active",
+          "value": "active",
+          "description": "",
+          "displayOrder": 0,
+          "hidden": false
+        },
+        {
+          "label": "Inactive",
+          "value": "inactive",
+          "description": "",
+          "displayOrder": 1,
+          "hidden": false
+        }
+      ],
+      "displayOrder": -1,
+      "calculated": false,
+      "externalOptions": false,
+      "hasUniqueValue": false,
+      "hidden": false,
+      "hubspotDefined": true,
+      "modificationMetadata": {
+        "archivable": true,
+        "readOnlyDefinition": true,
+        "readOnlyOptions": true,
+        "readOnlyValue": false
+      },
+      "formField": false,
+      "dataSensitivity": "non_sensitive"
+    },
+    {
+      "updatedAt": "2023-12-12T14:47:23.455Z",
+      "createdAt": "2021-06-22T17:52:16.232Z",
+      "name": "hs_all_assigned_business_unit_ids",
+      "label": "Business units",
+      "type": "enumeration",
+      "fieldType": "checkbox",
+      "description": "The business units this record is assigned to.",
+      "groupName": "contactinformation",
+      "options": [],
+      "displayOrder": -1,
+      "calculated": false,
+      "externalOptions": true,
+      "hasUniqueValue": false,
+      "hidden": false,
+      "hubspotDefined": true,
+      "modificationMetadata": {
+        "archivable": true,
+        "readOnlyDefinition": true,
+        "readOnlyValue": false
+      },
+      "formField": false,
+      "dataSensitivity": "non_sensitive"
+    },
+    {
+      "updatedAt": "2024-11-07T19:55:53.563Z",
+      "createdAt": "2020-06-30T15:57:37.203Z",
+      "name": "hs_predictivecontactscorebucket",
+      "label": "Lead Rating",
+      "type": "enumeration",
+      "fieldType": "radio",
+      "description": "The rating of this contact based on their predictive lead score",
+      "groupName": "contact_activity",
+      "options": [
+        {
+          "label": "1 Star",
+          "value": "bucket_1",
+          "description": "",
+          "displayOrder": 0,
+          "hidden": false
+        },
+        {
+          "label": "2 Stars",
+          "value": "bucket_2",
+          "description": "",
+          "displayOrder": 1,
+          "hidden": false
+        },
+        {
+          "label": "3 Stars",
+          "value": "bucket_3",
+          "description": "",
+          "displayOrder": 2,
+          "hidden": false
+        },
+        {
+          "label": "4 Stars",
+          "value": "bucket_4",
+          "description": "",
+          "displayOrder": 3,
+          "hidden": false
+        }
+      ],
+      "displayOrder": 999,
+      "calculated": false,
+      "externalOptions": false,
+      "hasUniqueValue": false,
+      "hidden": false,
+      "hubspotDefined": true,
+      "modificationMetadata": {
+        "archivable": true,
+        "readOnlyDefinition": true,
+        "readOnlyValue": true
+      },
+      "formField": false,
+      "dataSensitivity": "non_sensitive"
+    },
+    {
+      "updatedAt": "2024-11-06T20:48:27.060Z",
+      "createdAt": "2021-10-01T19:33:30.475Z",
+      "name": "hs_first_subscription_create_date",
+      "label": "First subscription create date",
+      "type": "datetime",
+      "fieldType": "calculation_rollup",
+      "description": "The create date of the first subscription by the contact.",
+      "groupName": "contactinformation",
+      "options": [],
+      "displayOrder": -1,
+      "calculated": false,
+      "externalOptions": false,
+      "hasUniqueValue": false,
+      "hidden": true,
+      "hubspotDefined": true,
+      "modificationMetadata": {
+        "archivable": true,
+        "readOnlyDefinition": true,
+        "readOnlyValue": true
+      },
+      "formField": false,
+      "dataSensitivity": "non_sensitive"
+    },
+    {
+      "updatedAt": "2024-05-06T18:44:18.865Z",
+      "createdAt": "2024-05-06T18:44:18.865Z",
+      "name": "hs_notes_last_activity",
+      "label": "Last Activity",
+      "type": "object_coordinates",
+      "fieldType": "text",
+      "description": "The coordinates of the last activity for a contact. This is set automatically by HubSpot based on user actions in the contact record.",
+      "groupName": "contactinformation",
+      "options": [],
+      "displayOrder": -1,
+      "calculated": false,
+      "externalOptions": false,
+      "hasUniqueValue": false,
+      "hidden": true,
+      "hubspotDefined": true,
+      "modificationMetadata": {
+        "archivable": true,
+        "readOnlyDefinition": true,
+        "readOnlyValue": true
+      },
+      "formField": false,
+      "dataSensitivity": "non_sensitive"
+    }
+  ]
+}
diff --git a/providers/iterable/delete_test.go b/providers/iterable/delete_test.go
new file mode 100644
index 00000000..8a26e23b
--- /dev/null
+++ b/providers/iterable/delete_test.go
@@ -0,0 +1,79 @@
+package iterable
+
+import (
+	"errors"
+	"net/http"
+	"testing"
+
+	"github.com/amp-labs/connectors"
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/test/utils/mockutils/mockcond"
+	"github.com/amp-labs/connectors/test/utils/mockutils/mockserver"
+	"github.com/amp-labs/connectors/test/utils/testroutines"
+	"github.com/amp-labs/connectors/test/utils/testutils"
+)
+
+func TestDelete(t *testing.T) { // nolint:funlen,cyclop
+	t.Parallel()
+
+	responseMissingListErr := testutils.DataFromFile(t, "delete-missing-list-bad-request.json")
+	responseList := testutils.DataFromFile(t, "delete-list.json")
+
+	tests := []testroutines.Delete{
+		{
+			Name:         "Write object must be included",
+			Server:       mockserver.Dummy(),
+			ExpectedErrs: []error{common.ErrMissingObjects},
+		},
+		{
+			Name:         "Write object and its ID must be included",
+			Input:        common.DeleteParams{ObjectName: "lists"},
+			Server:       mockserver.Dummy(),
+			ExpectedErrs: []error{common.ErrMissingRecordID},
+		},
+		{
+			Name:   "Cannot remove unknown object",
+			Input:  common.DeleteParams{ObjectName: "coupons", RecordId: "132"},
+			Server: mockserver.Dummy(),
+			ExpectedErrs: []error{
+				common.ErrOperationNotSupportedForObject,
+			},
+		},
+		{
+			Name:  "Cannot remove missing list",
+			Input: common.DeleteParams{ObjectName: "lists", RecordId: "5052803"},
+			Server: mockserver.Fixed{
+				Setup:  mockserver.ContentJSON(),
+				Always: mockserver.Response(http.StatusBadRequest, responseMissingListErr),
+			}.Server(),
+			ExpectedErrs: []error{
+				common.ErrBadRequest,
+				errors.New(`error.lists.deleteFailed(5052803)`), // nolint:goerr113
+			},
+		},
+		{
+			Name:  "Successful delete",
+			Input: common.DeleteParams{ObjectName: "lists", RecordId: "5052803"},
+			Server: mockserver.Conditional{
+				Setup: mockserver.ContentJSON(),
+				If: mockcond.And{
+					mockcond.MethodDELETE(),
+					mockcond.PathSuffix("/api/lists/5052803"),
+				},
+				Then: mockserver.Response(http.StatusOK, responseList),
+			}.Server(),
+			Expected:     &common.DeleteResult{Success: true},
+			ExpectedErrs: nil,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.Name, func(t *testing.T) {
+			t.Parallel()
+
+			tt.Run(t, func() (connectors.DeleteConnector, error) {
+				return constructTestConnector(tt.Server.URL)
+			})
+		})
+	}
+}
diff --git a/providers/iterable/read_test.go b/providers/iterable/read_test.go
index 5c83aeb9..9ee3cfa5 100644
--- a/providers/iterable/read_test.go
+++ b/providers/iterable/read_test.go
@@ -4,7 +4,6 @@ import (
 	"errors"
 	"net/http"
 	"testing"
-	"time"
 
 	"github.com/amp-labs/connectors"
 	"github.com/amp-labs/connectors/common"
@@ -20,7 +19,6 @@ func TestRead(t *testing.T) { //nolint:funlen,gocognit,cyclop,maintidx
 	responseCatalogsFirst := testutils.DataFromFile(t, "read-catalogs-1-first-page.json")
 	responseCatalogsLast := testutils.DataFromFile(t, "read-catalogs-2-second-page.json")
 	errorTemplatesInvalidSince := testutils.DataFromFile(t, "read-templates-invalid-since.html")
-	responseTemplatesSince := testutils.DataFromFile(t, "read-templates-since.json")
 	errorInvalidRestMethod := testutils.DataFromFile(t, "error-wrong-method.html")
 
 	tests := []testroutines.Read{
@@ -99,7 +97,7 @@ func TestRead(t *testing.T) { //nolint:funlen,gocognit,cyclop,maintidx
 			ExpectedErrs: nil,
 		},
 		{
-			Name:  "Incremental read of templates with invalid time format",
+			Name:  "Read of templates with invalid time format error", // Test exists for diverse HTML error handling.
 			Input: common.ReadParams{ObjectName: "templates", Fields: connectors.Fields("templateId")},
 			Server: mockserver.Fixed{
 				Setup:  mockserver.ContentHTML(),
@@ -111,51 +109,6 @@ func TestRead(t *testing.T) { //nolint:funlen,gocognit,cyclop,maintidx
 					"Unable to bind 1734632773073 into a date time!Acceptable formats are ISO8601"),
 			},
 		},
-		{
-			Name: "Incremental read of templates with required query parameter",
-			Input: common.ReadParams{
-				ObjectName: "templates",
-				Fields:     connectors.Fields("templateId"),
-				Since:      time.Date(2024, 12, 19, 18, 28, 41, 0, time.UTC),
-			},
-			Server: mockserver.Conditional{
-				Setup: mockserver.ContentJSON(),
-				If: mockcond.And{
-					mockcond.PathSuffix("/api/templates"),
-					mockcond.QueryParam("startDateTime", "2024-12-19T18:28:41Z"),
-				},
-				Then: mockserver.Response(http.StatusOK, responseTemplatesSince),
-			}.Server(),
-			Comparator: testroutines.ComparatorSubsetRead,
-			Expected: &common.ReadResult{
-				Rows: 3,
-				Data: []common.ReadResultRow{{
-					Fields: map[string]any{
-						"templateid": float64(15870445),
-					},
-					Raw: map[string]any{
-						"creatorUserId": "iterablepartners@gmail.com",
-					},
-				}, {
-					Fields: map[string]any{
-						"templateid": float64(15870451),
-					},
-					Raw: map[string]any{
-						"creatorUserId": "john@gmail.com",
-					},
-				}, {
-					Fields: map[string]any{
-						"templateid": float64(15870455),
-					},
-					Raw: map[string]any{
-						"creatorUserId": "emily@gmail.com",
-					},
-				}},
-				NextPage: "",
-				Done:     true,
-			},
-			ExpectedErrs: nil,
-		},
 	}
 
 	for _, tt := range tests {
diff --git a/providers/iterable/test/delete-list.json b/providers/iterable/test/delete-list.json
new file mode 100644
index 00000000..57c7360a
--- /dev/null
+++ b/providers/iterable/test/delete-list.json
@@ -0,0 +1,5 @@
+{
+  "msg": "List 5052803 in Project 24382 was successfully deleted.",
+  "code": "Success",
+  "params": null
+}
diff --git a/providers/iterable/test/delete-missing-list-bad-request.json b/providers/iterable/test/delete-missing-list-bad-request.json
new file mode 100644
index 00000000..28356515
--- /dev/null
+++ b/providers/iterable/test/delete-missing-list-bad-request.json
@@ -0,0 +1,5 @@
+{
+  "msg": "error.lists.deleteFailed(5052803)",
+  "code": "BadParams",
+  "params": null
+}
diff --git a/providers/iterable/test/read-templates-since.json b/providers/iterable/test/read-templates-since.json
deleted file mode 100644
index 6507c5fd..00000000
--- a/providers/iterable/test/read-templates-since.json
+++ /dev/null
@@ -1,28 +0,0 @@
-{
-  "templates": [
-    {
-      "templateId": 15870445,
-      "createdAt": 1734632955626,
-      "updatedAt": 1734632955626,
-      "name": "Template created on Thu, Dec 19 2024, 10:29 AM",
-      "creatorUserId": "iterablepartners@gmail.com",
-      "messageTypeId": 134467
-    },
-    {
-      "templateId": 15870451,
-      "createdAt": 1734632981784,
-      "updatedAt": 1734632981784,
-      "name": "Template created on Thu, Dec 19 2024, 10:29 AM",
-      "creatorUserId": "john@gmail.com",
-      "messageTypeId": 134467
-    },
-    {
-      "templateId": 15870455,
-      "createdAt": 1734632997386,
-      "updatedAt": 1734632997386,
-      "name": "Template created on Thu, Dec 19 2024, 10:29 AM",
-      "creatorUserId": "emily@gmail.com",
-      "messageTypeId": 134467
-    }
-  ]
-}
diff --git a/providers/iterable/test/write-catalog.json b/providers/iterable/test/write-catalog.json
new file mode 100644
index 00000000..1a2bef6b
--- /dev/null
+++ b/providers/iterable/test/write-catalog.json
@@ -0,0 +1,9 @@
+{
+  "msg": "",
+  "code": "Success",
+  "params": {
+    "id": 125254,
+    "name": "electronics",
+    "url": "/api/catalogs/electronics"
+  }
+}
diff --git a/providers/iterable/test/write-list.json b/providers/iterable/test/write-list.json
new file mode 100644
index 00000000..cfa5f1b9
--- /dev/null
+++ b/providers/iterable/test/write-list.json
@@ -0,0 +1,3 @@
+{
+  "listId": 5064151
+}
diff --git a/providers/iterable/test/write-push-template.json b/providers/iterable/test/write-push-template.json
new file mode 100644
index 00000000..29afe47b
--- /dev/null
+++ b/providers/iterable/test/write-push-template.json
@@ -0,0 +1,5 @@
+{
+  "msg": "Upserted 1 templates with IDs: 15939824",
+  "code": "Success",
+  "params": null
+}
diff --git a/providers/iterable/test/write-template-bad-request.html b/providers/iterable/test/write-template-bad-request.html
new file mode 100644
index 00000000..63ae7b7f
--- /dev/null
+++ b/providers/iterable/test/write-template-bad-request.html
@@ -0,0 +1,49 @@
+<!DOCTYPE html>
+<html lang="en">
+
+<head>
+    <title>Bad Request</title>
+
+    <style nonce="sr/x98ZfDIGnvHzHKmvWRA==" type="text/css">
+        html,
+        body,
+        pre {
+            margin: 0;
+            padding: 0;
+            font-family: Monaco, 'Lucida Console', monospace;
+            background: #ECECEC;
+        }
+
+        h1 {
+            margin: 0;
+            background: #AD632A;
+            padding: 20px 45px;
+            color: #fff;
+            text-shadow: 1px 1px 1px rgba(0, 0, 0, .3);
+            border-bottom: 1px solid #9F5805;
+            font-size: 28px;
+        }
+
+        p#detail {
+            margin: 0;
+            padding: 15px 45px;
+            background: #F6A960;
+            border-top: 4px solid #D29052;
+            color: #733512;
+            text-shadow: 1px 1px 1px rgba(255, 255, 255, .3);
+            font-size: 14px;
+            border-bottom: 1px solid #BA7F5B;
+        }
+    </style>
+</head>
+
+<body>
+<h1>Bad Request</h1>
+
+<p id="detail">
+    For request 'POST /api/templates/push/upsert' [Invalid Json: No content to map due to end-of-input]
+</p>
+
+</body>
+
+</html>
diff --git a/providers/iterable/test/write-template-invalid-request.json b/providers/iterable/test/write-template-invalid-request.json
new file mode 100644
index 00000000..d4ee6ea6
--- /dev/null
+++ b/providers/iterable/test/write-template-invalid-request.json
@@ -0,0 +1,7 @@
+{
+  "msg": "[/api/templates/push/upsert] Invalid JSON body",
+  "code": "BadJsonBody",
+  "params": {
+    "obj.buttons[0].actionIcon.iconType": "String value expected"
+  }
+}
\ No newline at end of file
diff --git a/providers/iterable/test/write-user.json b/providers/iterable/test/write-user.json
new file mode 100644
index 00000000..b993c96e
--- /dev/null
+++ b/providers/iterable/test/write-user.json
@@ -0,0 +1,5 @@
+{
+  "msg": "User successfully created/updated.",
+  "code": "Success",
+  "params": null
+}
diff --git a/providers/iterable/test/write-webhook-bad-request.txt b/providers/iterable/test/write-webhook-bad-request.txt
new file mode 100644
index 00000000..89d56236
--- /dev/null
+++ b/providers/iterable/test/write-webhook-bad-request.txt
@@ -0,0 +1,1 @@
+No webhook with id 0
\ No newline at end of file
diff --git a/providers/iterable/write_test.go b/providers/iterable/write_test.go
new file mode 100644
index 00000000..38c525af
--- /dev/null
+++ b/providers/iterable/write_test.go
@@ -0,0 +1,195 @@
+package iterable
+
+import (
+	"errors"
+	"net/http"
+	"testing"
+
+	"github.com/amp-labs/connectors"
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/test/utils/mockutils/mockcond"
+	"github.com/amp-labs/connectors/test/utils/mockutils/mockserver"
+	"github.com/amp-labs/connectors/test/utils/testroutines"
+	"github.com/amp-labs/connectors/test/utils/testutils"
+)
+
+func TestWrite(t *testing.T) { // nolint:funlen,cyclop
+	t.Parallel()
+
+	responseCatalog := testutils.DataFromFile(t, "write-catalog.json")
+	responseList := testutils.DataFromFile(t, "write-list.json")
+	responseUsers := testutils.DataFromFile(t, "write-user.json")
+	responsePushTemplate := testutils.DataFromFile(t, "write-push-template.json")
+	responseTemplateBadRequestHTML := testutils.DataFromFile(t, "write-template-bad-request.html")
+	responseTemplateInvalidRequestJSON := testutils.DataFromFile(t, "write-template-invalid-request.json")
+	responseWebhookTEXT := testutils.DataFromFile(t, "write-webhook-bad-request.txt")
+
+	tests := []testroutines.Write{
+		{
+			Name:         "Write object must be included",
+			Server:       mockserver.Dummy(),
+			ExpectedErrs: []error{common.ErrMissingObjects},
+		},
+		{
+			Name:         "Write needs data payload",
+			Input:        common.WriteParams{ObjectName: "users"},
+			Server:       mockserver.Dummy(),
+			ExpectedErrs: []error{common.ErrMissingRecordData},
+		},
+		{
+			Name:     "Unknown object name is not supported",
+			Input:    common.WriteParams{ObjectName: "orders", RecordData: "dummy"},
+			Server:   mockserver.Dummy(),
+			Expected: nil,
+			ExpectedErrs: []error{
+				common.ErrOperationNotSupportedForObject,
+			},
+		},
+		{
+			Name:  "No payload on template creation gives HTML error",
+			Input: common.WriteParams{ObjectName: "templatesPush", RecordData: "dummy"},
+			Server: mockserver.Conditional{
+				Setup: mockserver.ContentHTML(),
+				If: mockcond.And{
+					mockcond.MethodPOST(),
+					mockcond.PathSuffix("/api/templates/push/upsert"),
+				},
+				Then: mockserver.Response(http.StatusBadRequest, responseTemplateBadRequestHTML),
+			}.Server(),
+			ExpectedErrs: []error{
+				common.ErrBadRequest,
+				errors.New("Invalid Json: No content to map due to end-of-input"), // nolint:goerr113
+			},
+		},
+		{
+			Name:  "Invalid payload on template creation gives JSON error",
+			Input: common.WriteParams{ObjectName: "templatesPush", RecordData: "dummy"},
+			Server: mockserver.Conditional{
+				Setup: mockserver.ContentJSON(),
+				If: mockcond.And{
+					mockcond.MethodPOST(),
+					mockcond.PathSuffix("/api/templates/push/upsert"),
+				},
+				Then: mockserver.Response(http.StatusBadRequest, responseTemplateInvalidRequestJSON),
+			}.Server(),
+			ExpectedErrs: []error{
+				common.ErrBadRequest,
+				errors.New("Invalid JSON body"), // nolint:goerr113
+			},
+		},
+		{
+			Name:         "Catalog creation requires payload unlike provider API",
+			Input:        common.WriteParams{ObjectName: "catalogs", RecordData: "dummy"},
+			Server:       mockserver.Dummy(),
+			ExpectedErrs: []error{ErrCatalogCreate},
+		},
+		{
+			Name: "Create new catalog",
+			Input: common.WriteParams{
+				ObjectName: "catalogs",
+				RecordData: map[string]any{
+					"name": "electronics",
+				},
+			},
+			Server: mockserver.Conditional{
+				Setup: mockserver.ContentJSON(),
+				If: mockcond.And{
+					mockcond.MethodPOST(),
+					mockcond.PathSuffix("/api/catalogs/electronics"),
+				},
+				Then: mockserver.Response(http.StatusOK, responseCatalog),
+			}.Server(),
+			Expected: &common.WriteResult{
+				Success:  true,
+				RecordId: "125254",
+				Errors:   nil,
+				Data:     nil,
+			},
+			ExpectedErrs: nil,
+		},
+		{
+			Name:  "Create new list",
+			Input: common.WriteParams{ObjectName: "lists", RecordData: "dummy"},
+			Server: mockserver.Conditional{
+				Setup: mockserver.ContentJSON(),
+				If: mockcond.And{
+					mockcond.MethodPOST(),
+					mockcond.PathSuffix("/api/lists"),
+				},
+				Then: mockserver.Response(http.StatusOK, responseList),
+			}.Server(),
+			Expected: &common.WriteResult{
+				Success:  true,
+				RecordId: "5064151",
+				Errors:   nil,
+				Data:     nil,
+			},
+			ExpectedErrs: nil,
+		},
+		{
+			Name:  "Create new user",
+			Input: common.WriteParams{ObjectName: "users", RecordData: "dummy"},
+			Server: mockserver.Conditional{
+				Setup: mockserver.ContentJSON(),
+				If: mockcond.And{
+					mockcond.MethodPOST(),
+					mockcond.PathSuffix("/api/users/update"),
+				},
+				Then: mockserver.Response(http.StatusOK, responseUsers),
+			}.Server(),
+			Expected: &common.WriteResult{
+				Success:  true,
+				RecordId: "",
+				Errors:   nil,
+				Data:     nil,
+			},
+			ExpectedErrs: nil,
+		},
+		{
+			Name:  "Create new push template with record ID extraction",
+			Input: common.WriteParams{ObjectName: "templatesPush", RecordData: "dummy"},
+			Server: mockserver.Conditional{
+				Setup: mockserver.ContentJSON(),
+				If: mockcond.And{
+					mockcond.MethodPOST(),
+					mockcond.PathSuffix("/api/templates/push/upsert"),
+				},
+				Then: mockserver.Response(http.StatusOK, responsePushTemplate),
+			}.Server(),
+			Expected: &common.WriteResult{
+				Success:  true,
+				RecordId: "15939824",
+				Errors:   nil,
+				Data:     nil,
+			},
+			ExpectedErrs: nil,
+		},
+		{
+			Name:  "Bad request creating webhook returns plain text error",
+			Input: common.WriteParams{ObjectName: "webhooks", RecordData: "dummy"},
+			Server: mockserver.Conditional{
+				Setup: mockserver.ContentText(),
+				If: mockcond.And{
+					mockcond.MethodPOST(),
+					mockcond.PathSuffix("/api/webhooks"),
+				},
+				Then: mockserver.Response(http.StatusBadRequest, responseWebhookTEXT),
+			}.Server(),
+			ExpectedErrs: []error{
+				common.ErrCaller,
+				errors.New("No webhook with id 0"), // nolint:goerr113
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		// nolint:varnamelen
+		t.Run(tt.Name, func(t *testing.T) {
+			t.Parallel()
+
+			tt.Run(t, func() (connectors.WriteConnector, error) {
+				return constructTestConnector(tt.Server.URL)
+			})
+		})
+	}
+}
diff --git a/test/hubspot/auth-metadata/main.go b/test/hubspot/auth-metadata/main.go
new file mode 100644
index 00000000..ef235d48
--- /dev/null
+++ b/test/hubspot/auth-metadata/main.go
@@ -0,0 +1,31 @@
+package main
+
+import (
+	"context"
+	"os"
+	"os/signal"
+	"syscall"
+
+	connTest "github.com/amp-labs/connectors/test/hubspot"
+	"github.com/amp-labs/connectors/test/utils"
+)
+
+func main() {
+	// Handle Ctrl-C gracefully.
+	ctx, done := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+	defer done()
+
+	// Set up slog logging.
+	utils.SetupLogging()
+
+	// Get the Hubspot connector.
+	conn := connTest.GetHubspotConnector(ctx)
+
+	postAuthInfo, err := conn.GetPostAuthInfo(ctx)
+
+	if err != nil {
+		utils.Fail("error getting post auth info", "error", err)
+	}
+
+	utils.DumpJSON(postAuthInfo, os.Stdout)
+}
diff --git a/test/hubspot/metadata/main.go b/test/hubspot/metadata/main.go
index ef235d48..4c9db340 100644
--- a/test/hubspot/metadata/main.go
+++ b/test/hubspot/metadata/main.go
@@ -2,14 +2,20 @@ package main
 
 import (
 	"context"
-	"os"
+	"log/slog"
 	"os/signal"
 	"syscall"
 
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/internal/datautils"
 	connTest "github.com/amp-labs/connectors/test/hubspot"
 	"github.com/amp-labs/connectors/test/utils"
 )
 
+var objectName = "contacts"
+
+// we want to compare fields returned by read and schema properties provided by metadata methods
+// they must match for all such objects
 func main() {
 	// Handle Ctrl-C gracefully.
 	ctx, done := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
@@ -18,14 +24,37 @@ func main() {
 	// Set up slog logging.
 	utils.SetupLogging()
 
-	// Get the Hubspot connector.
 	conn := connTest.GetHubspotConnector(ctx)
 
-	postAuthInfo, err := conn.GetPostAuthInfo(ctx)
+	metadata, err := conn.ListObjectMetadata(ctx, []string{
+		objectName,
+	})
+	if err != nil {
+		utils.Fail("error listing metadata for Hubspot", "error", err)
+	}
+
+	slog.Info("Read object using all fields from ListObjectMetadata")
+
+	requestFields := datautils.Map[string, string](metadata.Result[objectName].FieldsMap).KeySet()
 
+	response, err := conn.Read(ctx, common.ReadParams{
+		ObjectName: objectName,
+		Fields:     requestFields,
+	})
 	if err != nil {
-		utils.Fail("error getting post auth info", "error", err)
+		utils.Fail("error reading from Hubspot", "error", err)
+	} else {
+		if response.Rows == 0 {
+			utils.Fail("expected to read at least one record", "error", err)
+		}
+
+		givenFields := datautils.Map[string, any](response.Data[0].Fields).KeySet()
+
+		difference := givenFields.Diff(requestFields)
+		if len(difference) != 0 {
+			utils.Fail("connector read didn't match requested fields", "difference", difference)
+		}
 	}
 
-	utils.DumpJSON(postAuthInfo, os.Stdout)
+	slog.Info("==> success fields requested from ListObjectMetadata are all present in Read.")
 }
diff --git a/test/hubspot/read-associations/main.go b/test/hubspot/read-associations/main.go
new file mode 100644
index 00000000..1357f3c7
--- /dev/null
+++ b/test/hubspot/read-associations/main.go
@@ -0,0 +1,49 @@
+package main
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"os/signal"
+	"syscall"
+	"time"
+
+	"github.com/amp-labs/connectors"
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/logging"
+	connTest "github.com/amp-labs/connectors/test/hubspot"
+	"github.com/amp-labs/connectors/test/utils"
+)
+
+func main() {
+	// Handle Ctrl-C gracefully.
+	ctx, done := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+	defer done()
+
+	// Set up slog logging.
+	utils.SetupLogging()
+
+	// Turn on logging
+	ctx = logging.WithLoggerEnabled(ctx, true)
+
+	// Get the Hubspot connector.
+	conn := connTest.GetHubspotConnector(ctx)
+
+	res, err := conn.Read(ctx, common.ReadParams{
+		ObjectName: "companies",
+		Fields:     connectors.Fields("name"),
+		NextPage:   "",
+		Since:      time.Now().Add(-24 * 365 * 10 * time.Hour), // 10 years should cover it
+		AssociatedObjects: []string{
+			"contacts",
+		},
+	})
+	if err != nil {
+		utils.Fail("error reading from hubspot", "error", err)
+	}
+
+	fmt.Println("Reading contacts..") //nolint:forbidigo
+
+	// Dump the result.
+	utils.DumpJSON(res, os.Stdout)
+}
diff --git a/test/iterable/write-delete/main.go b/test/iterable/write-delete/main.go
new file mode 100644
index 00000000..3f14c1e4
--- /dev/null
+++ b/test/iterable/write-delete/main.go
@@ -0,0 +1,123 @@
+package main
+
+import (
+	"context"
+	"log/slog"
+	"os/signal"
+	"strconv"
+	"syscall"
+
+	"github.com/amp-labs/connectors"
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/providers/iterable"
+	connTest "github.com/amp-labs/connectors/test/iterable"
+	"github.com/amp-labs/connectors/test/utils"
+	"github.com/amp-labs/connectors/test/utils/mockutils"
+	"github.com/brianvoe/gofakeit/v6"
+)
+
+type ListsPayload struct {
+	Name        string `json:"name"`
+	Description string `json:"description"`
+}
+
+var objectName = "lists" // nolint: gochecknoglobals
+
+func main() {
+	// Handle Ctrl-C gracefully.
+	ctx, done := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+	defer done()
+
+	// Set up slog logging.
+	utils.SetupLogging()
+
+	conn := connTest.GetIterableConnector(ctx)
+
+	slog.Info("> TEST Create/Delete list")
+	slog.Info("Creating list")
+
+	name := gofakeit.Name()
+	createLists(ctx, conn, &ListsPayload{
+		Name:        name,
+		Description: gofakeit.Name(), // this should be a relatively short field
+	})
+
+	slog.Info("Reading lists")
+
+	res := readLists(ctx, conn)
+
+	slog.Info("Finding recently created list")
+
+	list := searchList(res, "name", name)
+	listID := listIdentifierAsString(list)
+
+	slog.Info("Removing this list")
+	removeLists(ctx, conn, listID)
+	slog.Info("> Successful test completion")
+}
+
+func listIdentifierAsString(list map[string]any) string {
+	id, ok := list["id"].(float64)
+	if !ok {
+		return ""
+	}
+
+	listIdentifier := int64(id)
+
+	return strconv.FormatInt(listIdentifier, 10)
+}
+
+func searchList(res *common.ReadResult, key, value string) map[string]any {
+	for _, data := range res.Data {
+		if mockutils.DoesObjectCorrespondToString(data.Fields[key], value) {
+			return data.Fields
+		}
+	}
+
+	utils.Fail("error finding a list")
+
+	return nil
+}
+
+func readLists(ctx context.Context, conn *iterable.Connector) *common.ReadResult {
+	res, err := conn.Read(ctx, common.ReadParams{
+		ObjectName: objectName,
+		Fields:     connectors.Fields("id", "name", "description"),
+	})
+	if err != nil {
+		utils.Fail("error reading from Iterable", "error", err)
+	}
+
+	return res
+}
+
+func createLists(ctx context.Context, conn *iterable.Connector, payload *ListsPayload) *common.WriteResult {
+	res, err := conn.Write(ctx, common.WriteParams{
+		ObjectName: objectName,
+		RecordId:   "",
+		RecordData: payload,
+	})
+	if err != nil {
+		utils.Fail("error writing to Iterable", "error", err)
+	}
+
+	if !res.Success {
+		utils.Fail("failed to create a list")
+	}
+
+	return res
+}
+
+func removeLists(ctx context.Context, conn *iterable.Connector, listID string) {
+	res, err := conn.Delete(ctx, common.DeleteParams{
+		ObjectName: objectName,
+		RecordId:   listID,
+	})
+	if err != nil {
+		utils.Fail("error deleting for Iterable", "error", err)
+	}
+
+	if !res.Success {
+		utils.Fail("failed to remove a list")
+	}
+}
diff --git a/test/utils/mockutils/metadataResult.go b/test/utils/mockutils/metadataResult.go
index 02dd6ef1..d4e67514 100644
--- a/test/utils/mockutils/metadataResult.go
+++ b/test/utils/mockutils/metadataResult.go
@@ -3,6 +3,7 @@ package mockutils
 import (
 	"errors"
 	"fmt"
+	"reflect"
 	"strings"
 
 	"github.com/amp-labs/connectors/common"
@@ -15,7 +16,7 @@ type metadataResultComparator struct{}
 // SubsetFields checks that expected ListObjectMetadataResult fields are a subset of actual metadata result.
 func (metadataResultComparator) SubsetFields(actual, expected *common.ListObjectMetadataResult) bool {
 	if len(expected.Result) == 0 {
-		invalidTest("please specify expected FieldsMap response")
+		invalidTest("please specify expected Fields response")
 	}
 
 	for objectName, expectedMetadata := range expected.Result {
@@ -28,6 +29,18 @@ func (metadataResultComparator) SubsetFields(actual, expected *common.ListObject
 			return false
 		}
 
+		for k, v := range expectedMetadata.Fields {
+			value, ok := actualMetadata.Fields[k]
+			if !ok {
+				return false
+			}
+
+			if !reflect.DeepEqual(value, v) {
+				return false
+			}
+		}
+
+		// For backwards compatability the FieldsMap is checked alongside
 		for k, v := range expectedMetadata.FieldsMap {
 			value, ok := actualMetadata.FieldsMap[k]
 			if !ok {
diff --git a/test/utils/mockutils/mockserver/handlers.go b/test/utils/mockutils/mockserver/handlers.go
index 5b9c117e..a0818c29 100644
--- a/test/utils/mockutils/mockserver/handlers.go
+++ b/test/utils/mockutils/mockserver/handlers.go
@@ -23,6 +23,13 @@ func ContentHTML() http.HandlerFunc {
 	}
 }
 
+// ContentText is a setup handler, which configures server to use Plain Text.
+func ContentText() http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Content-Type", "text/plain")
+	}
+}
+
 // ContentMIME is a setup handler, which configures custom media type.
 func ContentMIME(mediaType string) http.HandlerFunc {
 	return func(w http.ResponseWriter, r *http.Request) {