Repository: amp-labs/connectors
Pull request #307 (2024-05-09 15:04:45)

feat: Implement Outreach ListMetadata
Implemented the ListMetdata function for the Outreach connector. Loops through the objects one by one and from the response we generate the metadata field values or errors.
- Include tests for the success and failures.

Running the tests:
<img width="1279" alt="Screenshot 2024-05-09 at 17 56 04" src="https://github.com/amp-labs/connectors/assets/52887226/6e5ba37a-6364-4633-bf3c-be4d5226c351">


Patch:
diff --git a/.github/PULL_REQUEST_TEMPLATE/proxy_connector.md b/.github/PULL_REQUEST_TEMPLATE/proxy_connector.md
new file mode 100644
index 00000000..b3f52a15
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE/proxy_connector.md
@@ -0,0 +1,23 @@
+## Testing
+### GET
+URL: <localhost:4444/v2/some-api-call>
+Postman screenshot (must show the request URL, the response status code & body clearly)
+
+### 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
+Wrong verb applied, invalid path.
+
+
+## Pagination
+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/interpreter/statusCode.go b/common/interpreter/statusCode.go
new file mode 100644
index 00000000..bc6a3ec1
--- /dev/null
+++ b/common/interpreter/statusCode.go
@@ -0,0 +1,34 @@
+package interpreter
+
+import (
+	"net/http"
+
+	"github.com/amp-labs/connectors/common"
+)
+
+func DefaultStatusCodeMappingToErr(res *http.Response, body []byte) error { // nolint:cyclop
+	switch res.StatusCode {
+	case http.StatusBadRequest:
+		return common.ErrBadRequest
+	case http.StatusUnauthorized:
+		return common.ErrAccessToken
+	case http.StatusForbidden:
+		return common.ErrForbidden
+	case http.StatusNotFound:
+		return common.ErrBadRequest // TODO more specific error
+	case http.StatusMethodNotAllowed:
+		return common.ErrBadRequest // TODO more specific error
+	case http.StatusPreconditionFailed:
+		return common.ErrBadRequest // TODO more specific error
+	case http.StatusRequestEntityTooLarge:
+		return common.ErrBadRequest // TODO more specific error
+	case http.StatusTooManyRequests:
+		return common.ErrLimitExceeded
+	case http.StatusNotImplemented:
+		return common.ErrNotImplemented
+	case http.StatusServiceUnavailable:
+		return common.ErrServer
+	default:
+		return common.InterpretError(res, body)
+	}
+}
diff --git a/common/json.go b/common/json.go
index f444b6be..74bead47 100644
--- a/common/json.go
+++ b/common/json.go
@@ -63,6 +63,17 @@ func (j *JSONHTTPClient) Post(ctx context.Context,
 	return parseJSONResponse(res, body)
 }
 
+func (j *JSONHTTPClient) Put(ctx context.Context,
+	url string, reqBody any, headers ...Header,
+) (*JSONHTTPResponse, error) {
+	res, body, err := j.HTTPClient.Put(ctx, url, reqBody, addAcceptJSONHeader(headers)) //nolint:bodyclose
+	if err != nil {
+		return nil, err
+	}
+
+	return parseJSONResponse(res, body)
+}
+
 func (j *JSONHTTPClient) Patch(ctx context.Context,
 	url string, reqBody any, headers ...Header,
 ) (*JSONHTTPResponse, error) {
diff --git a/common/jsonquery/converters.go b/common/jsonquery/converters.go
new file mode 100644
index 00000000..2fb0b248
--- /dev/null
+++ b/common/jsonquery/converters.go
@@ -0,0 +1,53 @@
+package jsonquery
+
+import (
+	"errors"
+
+	"github.com/spyzhov/ajson"
+)
+
+// Convertor provides common conversion methods from ajson to go types.
+var Convertor = convertor{} //nolint:gochecknoglobals
+
+type convertor struct{}
+
+func (convertor) ArrayToMap(arr []*ajson.Node) ([]map[string]any, error) {
+	output := make([]map[string]any, 0, len(arr))
+
+	for _, v := range arr {
+		if !v.IsObject() {
+			return nil, ErrNotObject
+		}
+
+		data, err := v.Unpack()
+		if err != nil {
+			return nil, err
+		}
+
+		m, ok := data.(map[string]interface{})
+		if !ok {
+			return nil, ErrNotObject
+		}
+
+		output = append(output, m)
+	}
+
+	return output, nil
+}
+
+func (convertor) ObjectToMap(node *ajson.Node) (map[string]any, error) {
+	data, err := node.GetObject()
+	if err != nil {
+		return nil, err
+	}
+
+	result := make(map[string]any)
+	for k, v := range data {
+		result[k], err = v.Unpack()
+		if err != nil {
+			return nil, errors.Join(err, ErrUnpacking)
+		}
+	}
+
+	return result, nil
+}
diff --git a/common/jsonquery/defaults.go b/common/jsonquery/defaults.go
new file mode 100644
index 00000000..8017a848
--- /dev/null
+++ b/common/jsonquery/defaults.go
@@ -0,0 +1,27 @@
+package jsonquery
+
+func (q *Query) IntegerWithDefault(key string, defaultValue int64) (int64, error) {
+	result, err := q.Integer(key, true)
+	if err != nil {
+		return 0, err
+	}
+
+	if result == nil {
+		return defaultValue, nil
+	}
+
+	return *result, nil
+}
+
+func (q *Query) StrWithDefault(key string, defaultValue string) (string, error) {
+	result, err := q.Str(key, true)
+	if err != nil {
+		return "", err
+	}
+
+	if result == nil {
+		return defaultValue, nil
+	}
+
+	return *result, nil
+}
diff --git a/common/jsonquery/errors.go b/common/jsonquery/errors.go
new file mode 100644
index 00000000..a504dfaf
--- /dev/null
+++ b/common/jsonquery/errors.go
@@ -0,0 +1,33 @@
+package jsonquery
+
+import (
+	"errors"
+	"fmt"
+)
+
+var (
+	ErrKeyNotFound = errors.New("key not found")
+	ErrNullJSON    = errors.New("value of JSON key is null")
+	ErrNotArray    = errors.New("JSON value is not an array")
+	ErrNotObject   = errors.New("JSON value is not an object")
+	ErrNotString   = errors.New("JSON value is not a string")
+	ErrNotNumeric  = errors.New("JSON value is not a numeric")
+	ErrNotInteger  = errors.New("JSON value is not an integer")
+	ErrUnpacking   = errors.New("failed to unpack ajson node")
+)
+
+func handleNullNode(key string, optional bool) error {
+	if optional {
+		return nil
+	}
+
+	return formatProblematicKeyError(key, ErrNullJSON)
+}
+
+func formatProblematicKeyError(key string, baseErr error) error {
+	return fmt.Errorf("problematic key: %v %w", key, baseErr)
+}
+
+func createKeyNotFoundErr(key string) error {
+	return errors.Join(ErrKeyNotFound, fmt.Errorf("key: [%v]", key)) // nolint:goerr113
+}
diff --git a/common/jsonquery/nested.go b/common/jsonquery/nested.go
new file mode 100644
index 00000000..45f0b0ae
--- /dev/null
+++ b/common/jsonquery/nested.go
@@ -0,0 +1,63 @@
+package jsonquery
+
+import (
+	"errors"
+	"fmt"
+	"strings"
+
+	"github.com/spyzhov/ajson"
+)
+
+func (q *Query) zoomIn() (*ajson.Node, error) {
+	var err error
+
+	node := q.node
+
+	// traverse nested JSON, use every key to zoom in
+	for _, key := range q.zoom {
+		if !node.HasKey(key) {
+			message := fmt.Sprintf("%v; zoom=%v", key, strings.Join(q.zoom, " "))
+
+			return nil, createKeyNotFoundErr(message)
+		}
+
+		node, err = node.GetKey(key)
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	return node, nil
+}
+
+func (q *Query) getInnerKey(targetKey string, optional bool) (*ajson.Node, error) {
+	zoomed, err := q.zoomIn()
+	if err != nil {
+		if errors.Is(err, ErrKeyNotFound) && optional {
+			// it is ok that part of zoom path didn't exist
+			return nil, nil // nolint:nilnil
+		}
+
+		return nil, err
+	}
+
+	if !zoomed.HasKey(targetKey) {
+		if optional {
+			// null value in payload is allowed
+			return nil, nil // nolint:nilnil
+		} else {
+			return nil, createKeyNotFoundErr(targetKey)
+		}
+	}
+
+	targetNode, err := zoomed.GetKey(targetKey)
+	if err != nil {
+		return nil, err
+	}
+
+	if targetNode.IsNull() {
+		return nil, handleNullNode(targetKey, optional)
+	}
+
+	return targetNode, nil
+}
diff --git a/common/jsonquery/queries.go b/common/jsonquery/queries.go
new file mode 100644
index 00000000..605ec5cd
--- /dev/null
+++ b/common/jsonquery/queries.go
@@ -0,0 +1,113 @@
+package jsonquery
+
+import (
+	"math"
+
+	"github.com/spyzhov/ajson"
+)
+
+// Query is a helpful wrapper of ajson library that adds errors when querying JSON payload.
+//
+// Usage examples, where node is JSON parsed via ajson library:
+//
+//	->	Must get *int64:	jsonquery.New(node).Integer("num", false)
+//	->	Optional *string:	jsonquery.New(node).String("text", true)
+//	->	Nested array:		jsonquery.New(node, "your", "path", "to", "array").Array("list", false)
+type Query struct {
+	node *ajson.Node
+	zoom []string
+}
+
+// New constructs query searching for key. Extra keys are preceding forming a zoom path.
+func New(node *ajson.Node, zoom ...string) *Query {
+	return &Query{
+		node: node,
+		zoom: zoom,
+	}
+}
+
+func (q *Query) Object(key string, optional bool) (*ajson.Node, error) {
+	node, err := q.getInnerKey(key, optional)
+	if err != nil {
+		return nil, err
+	}
+
+	if node == nil {
+		return nil, nil // nolint:nilnil
+	}
+
+	if !node.IsObject() {
+		return nil, ErrNotObject
+	}
+
+	return node, nil
+}
+
+func (q *Query) Integer(key string, optional bool) (*int64, error) {
+	node, err := q.getInnerKey(key, optional)
+	if err != nil {
+		return nil, err
+	}
+
+	if node == nil {
+		return nil, nil // nolint:nilnil
+	}
+
+	count, err := node.GetNumeric()
+	if err != nil {
+		return nil, ErrNotNumeric
+	}
+
+	if math.Mod(count, 1.0) != 0 {
+		return nil, ErrNotInteger
+	}
+
+	result := int64(count)
+
+	return &result, nil
+}
+
+func (q *Query) Str(key string, optional bool) (*string, error) {
+	node, err := q.getInnerKey(key, optional)
+	if err != nil {
+		return nil, err
+	}
+
+	if node == nil {
+		return nil, nil // nolint:nilnil
+	}
+
+	txt, err := node.GetString()
+	if err != nil {
+		return nil, ErrNotString
+	}
+
+	return &txt, nil
+}
+
+func (q *Query) Array(key string, optional bool) ([]*ajson.Node, error) {
+	node, err := q.getInnerKey(key, optional)
+	if err != nil {
+		return nil, err
+	}
+
+	if node == nil {
+		return nil, nil // nolint:nilnil
+	}
+
+	arr, err := node.GetArray()
+	if err != nil {
+		return nil, formatProblematicKeyError(key, ErrNotArray)
+	}
+
+	return arr, nil
+}
+
+func (q *Query) ArraySize(key string) (int64, error) {
+	arr, err := q.Array(key, false)
+	if err != nil {
+		return 0, err
+	}
+
+	return int64(len(arr)), nil
+}
diff --git a/common/paramsbuilder/params.go b/common/paramsbuilder/params.go
index 17ec169b..cd0fe2fb 100644
--- a/common/paramsbuilder/params.go
+++ b/common/paramsbuilder/params.go
@@ -98,5 +98,9 @@ type APIModule struct {
 }
 
 func (a APIModule) String() string {
+	if len(a.Label) == 0 {
+		return a.Version
+	}
+
 	return fmt.Sprintf("%s/%s", a.Label, a.Version)
 }
diff --git a/common/parse.go b/common/parse.go
index b24dd65e..8e82737f 100644
--- a/common/parse.go
+++ b/common/parse.go
@@ -1,13 +1,13 @@
 package common
 
 import (
-	"errors"
-	"math"
 	"strings"
 
 	"github.com/spyzhov/ajson"
 )
 
+type NextPageFunc func(*ajson.Node) (string, error)
+
 // ParseResult parses the response from a provider into a ReadResult. A 2xx return type is assumed.
 // The sizeFunc, recordsFunc, nextPageFunc, and marshalFunc are used to extract the relevant data from the response.
 // The sizeFunc returns the total number of records in the response.
@@ -75,105 +75,3 @@ func ExtractLowercaseFieldsFromRaw(fields []string, record map[string]interface{
 
 	return out
 }
-
-var (
-	ErrNotArray   = errors.New("JSON value is not an array")
-	ErrNotObject  = errors.New("JSON value is not an object")
-	ErrNotString  = errors.New("JSON value is not a string")
-	ErrNotNumeric = errors.New("JSON value is not a numeric")
-	ErrNotInteger = errors.New("JSON value is not an integer")
-
-	// JSONManager is a helpful wrapper of ajson library that adds errors when querying JSON payload
-	// and provides common conversion methods.
-	JSONManager = jsonManager{} //nolint:gochecknoglobals
-)
-
-type jsonManager struct{}
-
-func (jsonManager) ArrToMap(arr []*ajson.Node) ([]map[string]any, error) {
-	output := make([]map[string]any, 0, len(arr))
-
-	for _, v := range arr {
-		if !v.IsObject() {
-			return nil, ErrNotObject
-		}
-
-		data, err := v.Unpack()
-		if err != nil {
-			return nil, err
-		}
-
-		m, ok := data.(map[string]interface{})
-		if !ok {
-			return nil, ErrNotObject
-		}
-
-		output = append(output, m)
-	}
-
-	return output, nil
-}
-
-func (jsonManager) GetInteger(node *ajson.Node, key string) (int64, error) {
-	innerNode, err := node.GetKey(key)
-	if err != nil {
-		return 0, err
-	}
-
-	count, err := innerNode.GetNumeric()
-	if err != nil {
-		return 0, ErrNotNumeric
-	}
-
-	if math.Mod(count, 1.0) != 0 {
-		return 0, ErrNotInteger
-	}
-
-	return int64(count), nil
-}
-
-func (jsonManager) GetArr(node *ajson.Node, key string) ([]*ajson.Node, error) {
-	records, err := node.GetKey(key)
-	if err != nil {
-		return nil, err
-	}
-
-	arr, err := records.GetArray()
-	if err != nil {
-		return nil, ErrNotArray
-	}
-
-	return arr, nil
-}
-
-func (jsonManager) ArrSize(node *ajson.Node, key string) (int64, error) {
-	innerNode, err := node.GetKey(key)
-	if err != nil {
-		return 0, err
-	}
-
-	if !innerNode.IsArray() {
-		return 0, ErrNotArray
-	}
-
-	return int64(innerNode.Size()), nil
-}
-
-func (jsonManager) GetString(node *ajson.Node, key string, optional bool) (string, error) {
-	if optional && !node.HasKey(key) {
-		// null value in payload is allowed
-		return "", nil
-	}
-
-	innerNode, err := node.GetKey(key)
-	if err != nil {
-		return "", err
-	}
-
-	txt, err := innerNode.GetString()
-	if err != nil {
-		return "", ErrNotString
-	}
-
-	return txt, nil
-}
diff --git a/common/urlbuilder/url.go b/common/urlbuilder/url.go
new file mode 100644
index 00000000..b74cebba
--- /dev/null
+++ b/common/urlbuilder/url.go
@@ -0,0 +1,98 @@
+package urlbuilder
+
+import (
+	"errors"
+	"net/url"
+	"strings"
+)
+
+var ErrInvalidURL = errors.New("URL format is incorrect")
+
+// URL focuses on query params and treats the rest as immutable string.
+// You can use any URL string to construct this object.
+// Its primary goal is to "Expose Query Manipulation".
+// Under the hood it uses url.URL library.
+type URL struct {
+	delegate           *url.URL
+	queryParams        url.Values
+	encodingExceptions map[string]string
+}
+
+// New URL will be constructed given valid full url which may have query params.
+func New(base string) (*URL, error) {
+	delegate, err := url.Parse(base)
+	if err != nil {
+		return nil, errors.Join(err, ErrInvalidURL)
+	}
+
+	values, err := url.ParseQuery(delegate.RawQuery)
+	if err != nil {
+		return nil, errors.Join(err, ErrInvalidURL)
+	}
+
+	return &URL{
+		delegate:           delegate,
+		queryParams:        values,
+		encodingExceptions: nil,
+	}, nil
+}
+
+func (u *URL) WithQueryParamList(name string, values []string) {
+	u.queryParams[name] = values
+}
+
+func (u *URL) WithQueryParam(name, value string) {
+	u.queryParams[name] = []string{value}
+}
+
+func (u *URL) RemoveQueryParam(name string) {
+	delete(u.queryParams, name)
+}
+
+func (u *URL) AddEncodingExceptions(exceptions map[string]string) {
+	u.encodingExceptions = exceptions
+}
+
+// ToURL relies on String method.
+func (u *URL) ToURL() (*url.URL, error) {
+	// Current URL wrapper will be realised as equivalent to url.URL type.
+	// It must be done via String() which handles query params.
+	result, err := url.Parse(u.String())
+	if err != nil {
+		return nil, errors.Join(err, ErrInvalidURL)
+	}
+
+	return result, nil
+}
+
+func (u *URL) String() string {
+	// Everything stays the same
+	// The only thing that we alter in the delegate's query params
+	u.delegate.RawQuery = u.queryValuesToString()
+
+	return u.delegate.String()
+}
+
+// URL may have special encoding rules.
+// Those can be set via AddEncodingExceptions.
+func (u *URL) queryValuesToString() string {
+	result := u.queryParams.Encode()
+	if len(result) == 0 {
+		return ""
+	}
+
+	// We are not fully happy with strict encoding provided by url library
+	// some special symbols are allowed
+	for before, after := range u.encodingExceptions {
+		result = strings.ReplaceAll(result, before, after)
+	}
+
+	return result
+}
+
+func (u *URL) AddPath(paths ...string) *URL {
+	// replace delegate with a new URL
+	u.delegate = u.delegate.JoinPath(paths...)
+
+	return u
+}
diff --git a/connectors.go b/connectors.go
index 48e8550d..e25f5952 100644
--- a/connectors.go
+++ b/connectors.go
@@ -14,6 +14,7 @@ import (
 	"github.com/amp-labs/connectors/outreach"
 	"github.com/amp-labs/connectors/providers"
 	"github.com/amp-labs/connectors/salesforce"
+	"github.com/amp-labs/connectors/salesloft"
 )
 
 // Connector is an interface that can be used to implement a connector with
@@ -96,6 +97,9 @@ var Mock API[*mock.Connector, mock.Option] = mock.NewConnector //nolint:gocheckn
 // Outreach is an API that returns a new Outreach Connector.
 var Outreach API[*outreach.Connector, outreach.Option] = outreach.NewConnector //nolint:gochecknoglobals
 
+// Salesloft is an API that returns a new Salesloft Connector.
+var Salesloft API[*salesloft.Connector, salesloft.Option] = salesloft.NewConnector //nolint:gochecknoglobals
+
 // We re-export the following types so that they can be used by consumers of this library.
 type (
 	ReadParams               = common.ReadParams
diff --git a/go.mod b/go.mod
index 6fcbe969..cdadfd72 100644
--- a/go.mod
+++ b/go.mod
@@ -1,6 +1,6 @@
 module github.com/amp-labs/connectors
 
-go 1.21
+go 1.21.0
 
 require (
 	github.com/brianvoe/gofakeit/v6 v6.28.0
diff --git a/microsoftdynamicscrm/errors.go b/microsoftdynamicscrm/errors.go
index 31e5be23..f19ba643 100644
--- a/microsoftdynamicscrm/errors.go
+++ b/microsoftdynamicscrm/errors.go
@@ -5,7 +5,6 @@ import (
 	"fmt"
 	"net/http"
 
-	"github.com/amp-labs/connectors/common"
 	"github.com/amp-labs/connectors/common/interpreter"
 )
 
@@ -15,30 +14,7 @@ func (*Connector) interpretJSONError(res *http.Response, body []byte) error { //
 		return fmt.Errorf("interpretJSONError: %w %w", interpreter.ErrUnmarshal, err)
 	}
 
-	switch res.StatusCode {
-	case http.StatusBadRequest:
-		return createError(common.ErrBadRequest, apiError)
-	case http.StatusUnauthorized:
-		return createError(common.ErrAccessToken, apiError)
-	case http.StatusForbidden:
-		return createError(common.ErrForbidden, apiError)
-	case http.StatusNotFound:
-		return createError(common.ErrBadRequest, apiError) // TODO more specific error
-	case http.StatusMethodNotAllowed:
-		return createError(common.ErrBadRequest, apiError) // TODO more specific error
-	case http.StatusPreconditionFailed:
-		return createError(common.ErrBadRequest, apiError) // TODO more specific error
-	case http.StatusRequestEntityTooLarge:
-		return createError(common.ErrBadRequest, apiError) // TODO more specific error
-	case http.StatusTooManyRequests:
-		return createError(common.ErrLimitExceeded, apiError)
-	case http.StatusNotImplemented:
-		return createError(common.ErrNotImplemented, apiError)
-	case http.StatusServiceUnavailable:
-		return createError(common.ErrServer, apiError)
-	default:
-		return common.InterpretError(res, body)
-	}
+	return createError(interpreter.DefaultStatusCodeMappingToErr(res, body), apiError)
 }
 
 type CRMResponseError struct {
diff --git a/microsoftdynamicscrm/parse.go b/microsoftdynamicscrm/parse.go
index 0d90d848..03a9ab2b 100644
--- a/microsoftdynamicscrm/parse.go
+++ b/microsoftdynamicscrm/parse.go
@@ -2,24 +2,25 @@ package microsoftdynamicscrm
 
 import (
 	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/jsonquery"
 	"github.com/spyzhov/ajson"
 )
 
 func getTotalSize(node *ajson.Node) (int64, error) {
-	return common.JSONManager.ArrSize(node, "value")
+	return jsonquery.New(node).ArraySize("value")
 }
 
 func getRecords(node *ajson.Node) ([]map[string]any, error) {
-	arr, err := common.JSONManager.GetArr(node, "value")
+	arr, err := jsonquery.New(node).Array("value", false)
 	if err != nil {
 		return nil, err
 	}
 
-	return common.JSONManager.ArrToMap(arr)
+	return jsonquery.Convertor.ArrayToMap(arr)
 }
 
 func getNextRecordsURL(node *ajson.Node) (string, error) {
-	return common.JSONManager.GetString(node, "@odata.nextLink", true)
+	return jsonquery.New(node).StrWithDefault("@odata.nextLink", "")
 }
 
 func getMarshaledData(records []map[string]interface{}, fields []string) ([]common.ReadResultRow, error) {
diff --git a/microsoftdynamicscrm/read.go b/microsoftdynamicscrm/read.go
index 7d989533..d36c426f 100644
--- a/microsoftdynamicscrm/read.go
+++ b/microsoftdynamicscrm/read.go
@@ -3,30 +3,24 @@ package microsoftdynamicscrm
 import (
 	"context"
 	"fmt"
-	"net/url"
 	"strings"
 
 	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/urlbuilder"
 )
 
 // nolint:lll
 // Microsoft API supports other capabilities like filtering, grouping, and sorting which we can potentially tap into later.
 // See https://learn.microsoft.com/en-us/power-apps/developer/data-platform/webapi/query-data-web-api#odata-query-options
 func (c *Connector) Read(ctx context.Context, config common.ReadParams) (*common.ReadResult, error) {
-	var fullURL string
-
-	if len(config.NextPage) == 0 {
-		// First page
-		relativeURL := config.ObjectName + makeQueryValues(config)
-		fullURL = c.getURL(relativeURL)
-	} else {
-		// Next page
-		fullURL = config.NextPage.String()
+	link, err := c.buildReadURL(config)
+	if err != nil {
+		return nil, err
 	}
 
 	// always include annotations header
 	// response will describe enums, foreign relationship, etc.
-	rsp, err := c.get(ctx, fullURL, newPaginationHeader(DefaultPageSize), common.Header{
+	rsp, err := c.get(ctx, link.String(), newPaginationHeader(DefaultPageSize), common.Header{
 		Key:   "Prefer",
 		Value: `odata.include-annotations="*"`,
 	})
@@ -44,36 +38,28 @@ func (c *Connector) Read(ctx context.Context, config common.ReadParams) (*common
 	)
 }
 
-func newPaginationHeader(pageSize int) common.Header {
-	return common.Header{
-		Key:   "Prefer",
-		Value: fmt.Sprintf("odata.maxpagesize=%v", pageSize),
+func (c *Connector) buildReadURL(config common.ReadParams) (*urlbuilder.URL, error) {
+	if len(config.NextPage) != 0 {
+		// Next page
+		return constructURL(config.NextPage.String())
 	}
-}
 
-func makeQueryValues(config common.ReadParams) string {
-	queryValues := url.Values{}
+	// First page
+	link, err := constructURL(c.getURL(config.ObjectName))
+	if err != nil {
+		return nil, err
+	}
 
 	if len(config.Fields) != 0 {
-		queryValues.Add("$select", strings.Join(config.Fields, ","))
+		link.WithQueryParam("$select", strings.Join(config.Fields, ","))
 	}
 
-	result := queryValues.Encode()
-	if len(result) != 0 {
-		// TODO this is a hack. net/url encodes $. But we rely heavily on it
-		// same problem with "@" ex: @Microsoft.Dynamics.CRM.totalrecordcountlimitexceeded
-		// @ symbol is removed
-		for before, after := range map[string]string{
-			"%24select": "$select",
-		} {
-			result = strings.ReplaceAll(result, before, after)
-		}
-
-		result = strings.ReplaceAll(result, "%40", "@")
-		result = strings.ReplaceAll(result, "%2C", ",")
+	return link, nil
+}
 
-		return "?" + result
+func newPaginationHeader(pageSize int) common.Header {
+	return common.Header{
+		Key:   "Prefer",
+		Value: fmt.Sprintf("odata.maxpagesize=%v", pageSize),
 	}
-
-	return result
 }
diff --git a/microsoftdynamicscrm/url.go b/microsoftdynamicscrm/url.go
new file mode 100644
index 00000000..cf8339e2
--- /dev/null
+++ b/microsoftdynamicscrm/url.go
@@ -0,0 +1,21 @@
+package microsoftdynamicscrm
+
+import "github.com/amp-labs/connectors/common/urlbuilder"
+
+// Microsoft uses special symbology when making queries.
+var microsoftQueryEncodingExceptions = map[string]string{ //nolint:gochecknoglobals
+	"%40": "@",
+	"%24": "$",
+	"%2C": ",",
+}
+
+func constructURL(base string) (*urlbuilder.URL, error) {
+	link, err := urlbuilder.New(base)
+	if err != nil {
+		return nil, err
+	}
+
+	link.AddEncodingExceptions(microsoftQueryEncodingExceptions)
+
+	return link, nil
+}
diff --git a/outreach/README.md b/outreach/README.md
index 22145e31..74de52b9 100644
--- a/outreach/README.md
+++ b/outreach/README.md
@@ -14,11 +14,53 @@ Read is used to list all records of a given type. For example, if you want to li
 // Call Read to list records in a users object
 res, err := client.Read(context.TODO(),common.ReadParams{
 		ObjectName: "users",
-        })
+})
 if err != nil {
 	log.Fatal(err)
 }
 
 ```
 
+## Write
+Write is used to create/update objects in outreach connector. For an instance creating an emailAddress object, you use the Write method and `emailAddress` object   
+
+### Example usage
+```
+
+type Attribute struct {
+	Email     string `json:"email"`
+	EmailType string `json:"emailType"`
+	Order     int    `json:"order"`
+	Status    string `json:"status"`
+}
+
+type EmailAddress struct {
+	Attributes Attribute `json:"attributes"`
+	Type       string    `json:"type"`
+}
+
+// Create an outreach connector instance (any method you prefer)
+// This Assumes you named the instance client
+
+attribute := Attribute{
+		Email:     gofakeit.Email(),
+		EmailType: "email",
+		Order:     0,
+		Status:    "null",
+	}
+
+	config := common.WriteParams{
+		ObjectName: "emailAddresses",
+		RecordData: EmailAddress{
+			Attributes: attribute,
+			Type:       "emailAddress",
+		},
+	}
+
+result, err := client.Write(ctx, config)
+if err != nil {
+	log.Fatal(err)
+}
+```
+
 
diff --git a/outreach/errors.go b/outreach/errors.go
index 3ac48c17..ec950b35 100644
--- a/outreach/errors.go
+++ b/outreach/errors.go
@@ -9,6 +9,7 @@ var (
 	ErrNotArray      = errors.New("results data is not an array")
 	ErrNotObject     = errors.New("record is not an object")
 	ErrNotString     = errors.New("next is not a string")
+	ErrEmptyResponse = errors.New("empty response body")
 )
 
 func (c *Connector) HandleError(err error) error {
diff --git a/outreach/metadata.go b/outreach/metadata.go
new file mode 100644
index 00000000..f8597976
--- /dev/null
+++ b/outreach/metadata.go
@@ -0,0 +1,80 @@
+package outreach
+
+import (
+	"context"
+	"encoding/json"
+	"net/url"
+
+	"github.com/amp-labs/connectors/common"
+)
+
+type ObjectOKResponse struct {
+	Data []map[string]any `json:"data"`
+}
+
+func (c *Connector) ListObjectMetadata(ctx context.Context,
+	objectNames []string,
+) (*common.ListObjectMetadataResult, error) {
+	// Ensure that objectNames is not empty
+	if len(objectNames) == 0 {
+		return nil, common.ErrMissingObjects
+	}
+
+	objMetadata := common.ListObjectMetadataResult{
+		Result: make(map[string]common.ObjectMetadata),
+		Errors: make(map[string]error),
+	}
+
+	for _, obj := range objectNames {
+		// Constructing the  request url.
+		objURL, err := url.JoinPath(c.BaseURL, obj)
+		if err != nil {
+			return nil, err
+		}
+
+		res, err := c.get(ctx, objURL)
+		if err != nil {
+			objMetadata.Errors[obj] = err
+
+			continue
+		}
+
+		// Check nil response body, to avoid panic.
+		if res == nil || res.Body == nil {
+			objMetadata.Errors[obj] = ErrEmptyResponse
+
+			continue
+		}
+
+		metadata, err := metadataMapper(res.Body.Source())
+		if err != nil {
+			return nil, err
+		}
+
+		metadata.DisplayName = obj
+		objMetadata.Result[obj] = metadata
+	}
+
+	return &objMetadata, nil
+}
+
+func metadataMapper(body []byte) (common.ObjectMetadata, error) {
+	metadata := common.ObjectMetadata{
+		FieldsMap: make(map[string]string),
+	}
+
+	var response ObjectOKResponse
+
+	err := json.Unmarshal(body, &response)
+	if err != nil {
+		return metadata, err
+	}
+
+	for _, dataMap := range response.Data {
+		for attr := range dataMap {
+			metadata.FieldsMap[attr] = attr
+		}
+	}
+
+	return metadata, nil
+}
diff --git a/outreach/write.go b/outreach/write.go
new file mode 100644
index 00000000..5d2172a0
--- /dev/null
+++ b/outreach/write.go
@@ -0,0 +1,63 @@
+package outreach
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"net/url"
+
+	"github.com/amp-labs/connectors/common"
+)
+
+type WriteResponse struct {
+	Data map[string]any `json:"data"`
+}
+
+var JSONAPIContentTypeHeader = common.Header{ //nolint:gochecknoglobals
+	Key:   "Content-Type",
+	Value: "application/vnd.api+json",
+}
+
+type writeMethod func(context.Context, string, any, ...common.Header) (*common.JSONHTTPResponse, error)
+
+func (c *Connector) Write(ctx context.Context, config common.WriteParams) (*common.WriteResult, error) {
+	var write writeMethod
+
+	URL, err := url.JoinPath(c.BaseURL, config.ObjectName)
+	if err != nil {
+		return nil, err
+	}
+
+	if len(config.RecordId) > 0 {
+		URL, err = url.JoinPath(URL, config.RecordId)
+		if err != nil {
+			return nil, err
+		}
+
+		write = c.Client.Patch
+	} else {
+		write = c.Client.Post
+	}
+
+	// Outreach expects everything to be wrapped in a "data" object.
+	data := make(map[string]any)
+	data["data"] = config.RecordData
+
+	res, err := write(ctx, URL, data, JSONAPIContentTypeHeader)
+	if err != nil {
+		return nil, err
+	}
+
+	var response WriteResponse
+
+	err = json.Unmarshal(res.Body.Source(), &response)
+	if err != nil {
+		return nil, err
+	}
+
+	return &common.WriteResult{
+		Success:  true,
+		RecordId: fmt.Sprint(response.Data["id"]),
+		Data:     response.Data,
+	}, nil
+}
diff --git a/providers/catalog.go b/providers/catalog.go
index ce8052e2..e2ccaea5 100644
--- a/providers/catalog.go
+++ b/providers/catalog.go
@@ -5,60 +5,66 @@ package providers
 // ================================================================================
 
 const (
-	Mock                                Provider = "mock"
-	Salesforce                          Provider = "salesforce"
-	Hubspot                             Provider = "hubspot"
-	LinkedIn                            Provider = "linkedIn"
-	Salesloft                           Provider = "salesloft"
-	Outreach                            Provider = "outreach"
-	RingCentral                         Provider = "ringCentral"
-	Pipedrive                           Provider = "pipedrive"
-	Copper                              Provider = "copper"
-	ZohoCRM                             Provider = "zohoCRM"
-	Mural                               Provider = "mural"
-	Klaviyo                             Provider = "klaviyo"
-	Sellsy                              Provider = "sellsy"
-	Attio                               Provider = "attio"
-	Close                               Provider = "close"
-	Keap                                Provider = "keap"
+	Airtable                            Provider = "airtable"
+	AWeber                              Provider = "aWeber"
 	Asana                               Provider = "asana"
-	Dropbox                             Provider = "dropbox"
-	Notion                              Provider = "notion"
-	Gong                                Provider = "gong"
-	Zoom                                Provider = "zoom"
-	Intercom                            Provider = "intercom"
+	Atlassian                           Provider = "atlassian"
+	Attio                               Provider = "attio"
+	Box                                 Provider = "box"
+	Calendly                            Provider = "calendly"
 	Capsule                             Provider = "capsule"
-	Wrike                               Provider = "wrike"
+	ClickUp                             Provider = "clickup"
+	Close                               Provider = "close"
+	ConstantContact                     Provider = "constantContact"
+	Copper                              Provider = "copper"
+	Discord                             Provider = "discord"
 	Docusign                            Provider = "docusign"
 	DocusignDeveloper                   Provider = "docusignDeveloper"
-	Calendly                            Provider = "calendly"
-	AWeber                              Provider = "aWeber"
+	Dropbox                             Provider = "dropbox"
+	DropboxSign                         Provider = "dropboxSign"
+	Figma                               Provider = "figma"
+	Gainsight                           Provider = "gainsight"
 	GetResponse                         Provider = "getResponse"
-	ConstantContact                     Provider = "constantContact"
-	MicrosoftDynamics365CRM             Provider = "microsoftDynamics365CRM"
+	Gmail                               Provider = "gmail"
+	Gong                                Provider = "gong"
+	IroncladDemo                        Provider = "ironcladDemo"
+	IroncladEU                          Provider = "ironcladEU"
+	Ironclad                            Provider = "ironclad"
+	Google                              Provider = "google"
+	GoogleContacts                      Provider = "googleContacts"
+	HelpScoutMailbox                    Provider = "helpScoutMailbox"
+	Hubspot                             Provider = "hubspot"
+	Intercom                            Provider = "intercom"
+	Keap                                Provider = "keap"
+	Klaviyo                             Provider = "klaviyo"
+	LinkedIn                            Provider = "linkedIn"
 	MicrosoftDynamics365BusinessCentral Provider = "microsoftDynamics365BusinessCentral"
-	Gainsight                           Provider = "gainsight"
-	Box                                 Provider = "box"
-	ZendeskSupport                      Provider = "zendeskSupport"
-	ZendeskChat                         Provider = "zendeskChat"
-	WordPress                           Provider = "wordPress"
-	Airtable                            Provider = "airtable"
+	MicrosoftDynamics365CRM             Provider = "microsoftDynamics365CRM"
+	Miro                                Provider = "miro"
+	Mock                                Provider = "mock"
+	Monday                              Provider = "monday"
+	Mural                               Provider = "mural"
+	Notion                              Provider = "notion"
+	Outreach                            Provider = "outreach"
+	Pipedrive                           Provider = "pipedrive"
+	RingCentral                         Provider = "ringCentral"
+	Salesforce                          Provider = "salesforce"
+	Salesloft                           Provider = "salesloft"
+	Sellsy                              Provider = "sellsy"
 	Slack                               Provider = "slack"
-	HelpScoutMailbox                    Provider = "helpScoutMailbox"
-	Timely                              Provider = "timely"
-	Atlassian                           Provider = "atlassian"
-	Webflow                             Provider = "webflow"
 	Smartsheet                          Provider = "smartsheet"
 	StackExchange                       Provider = "stackExchange"
-	Google                              Provider = "google"
-	GoogleContacts                      Provider = "googleContacts"
-	GoogleMail                          Provider = "googleMail"
-	Monday                              Provider = "monday"
-	Figma                               Provider = "figma"
-	Miro                                Provider = "miro"
+	Timely                              Provider = "timely"
 	Typeform                            Provider = "typeform"
+	Webflow                             Provider = "webflow"
+	WordPress                           Provider = "wordPress"
+	Wrike                               Provider = "wrike"
+	ZendeskChat                         Provider = "zendeskChat"
+	ZendeskSupport                      Provider = "zendeskSupport"
+	ZohoCRM                             Provider = "zohoCRM"
+	Zoom                                Provider = "zoom"
 	Zuora                               Provider = "zuora"
-	DropboxSign                         Provider = "dropboxSign"
+	Aircall                             Provider = "aircall"
 )
 
 // ================================================================================
@@ -173,7 +179,7 @@ var catalog = CatalogType{ // nolint:gochecknoglobals
 				Upsert: false,
 				Delete: false,
 			},
-			Proxy:     false,
+			Proxy:     true,
 			Read:      false,
 			Subscribe: false,
 			Write:     false,
@@ -200,7 +206,7 @@ var catalog = CatalogType{ // nolint:gochecknoglobals
 				Upsert: false,
 				Delete: false,
 			},
-			Proxy:     false,
+			Proxy:     true,
 			Read:      false,
 			Subscribe: false,
 			Write:     false,
@@ -547,10 +553,15 @@ var catalog = CatalogType{ // nolint:gochecknoglobals
 		AuthType: Oauth2,
 		BaseURL:  "https://api.dropboxapi.com",
 		OauthOpts: OauthOpts{
+			GrantType:                 AuthorizationCode,
 			AuthURL:                   "https://www.dropbox.com/oauth2/authorize",
 			TokenURL:                  "https://api.dropboxapi.com/oauth2/token",
 			ExplicitScopesRequired:    false,
 			ExplicitWorkspaceRequired: false,
+			TokenMetadataFields: TokenMetadataFields{
+				ScopesField:      "scope",
+				ConsumerRefField: "account_id",
+			},
 		},
 		Support: Support{
 			BulkWrite: BulkWriteSupport{
@@ -559,7 +570,7 @@ var catalog = CatalogType{ // nolint:gochecknoglobals
 				Upsert: false,
 				Delete: false,
 			},
-			Proxy:     false,
+			Proxy:     true,
 			Read:      false,
 			Subscribe: false,
 			Write:     false,
@@ -604,8 +615,7 @@ var catalog = CatalogType{ // nolint:gochecknoglobals
 			ExplicitScopesRequired:    true,
 			ExplicitWorkspaceRequired: false,
 			TokenMetadataFields: TokenMetadataFields{
-				ScopesField:      "scope",
-				ConsumerRefField: "client_id",
+				ScopesField: "scope",
 			},
 		},
 		Support: Support{
@@ -671,40 +681,42 @@ var catalog = CatalogType{ // nolint:gochecknoglobals
 	},
 
 	// Docusign configuration
-	// TODO: we don't have a good way to get the server string yet. Need to make API call to /oauth/userinfo.
-	// Leaving this connector commented out until that is unblocked.
-	// Docusign: {
-	//	AuthType: Oauth2,
-	//	BaseURL:  "https://{{.server}}.docusign.net",
-	//	OauthOpts: OauthOpts{
-	//		AuthURL:                   "https://account.docusign.com/oauth/auth",
-	//		TokenURL:                  "https://account.docusign.com/oauth/token",
-	//		ExplicitScopesRequired:    true,
-	//		ExplicitWorkspaceRequired: false,
-	//	},
-	//	Support: Support{
-	//		BulkWrite: BulkWriteSupport{
-	//			Insert: false,
-	//			Update: false,
-	//			Upsert: false,
-	//			Delete: false,
-	//		},
-	//		Proxy:     false,
-	//		Read:      false,
-	//		Subscribe: false,
-	//		Write:     false,
-	//	},
-	// },
+	Docusign: {
+		AuthType: Oauth2,
+		BaseURL:  "https://{{.server}}.docusign.net",
+		OauthOpts: OauthOpts{
+			AuthURL:                   "https://account.docusign.com/oauth/auth",
+			TokenURL:                  "https://account.docusign.com/oauth/token",
+			ExplicitScopesRequired:    true,
+			ExplicitWorkspaceRequired: false,
+		},
+		Support: Support{
+			BulkWrite: BulkWriteSupport{
+				Insert: false,
+				Update: false,
+				Upsert: false,
+				Delete: false,
+			},
+			Proxy:     false,
+			Read:      false,
+			Subscribe: false,
+			Write:     false,
+		},
+	},
 
 	// Docusign Developer configuration
 	DocusignDeveloper: {
 		AuthType: Oauth2,
 		BaseURL:  "https://demo.docusign.net",
 		OauthOpts: OauthOpts{
+			GrantType:                 AuthorizationCode,
 			AuthURL:                   "https://account-d.docusign.com/oauth/auth",
 			TokenURL:                  "https://account-d.docusign.com/oauth/token",
 			ExplicitScopesRequired:    true,
 			ExplicitWorkspaceRequired: false,
+			TokenMetadataFields: TokenMetadataFields{
+				ScopesField: "scope",
+			},
 		},
 		Support: Support{
 			BulkWrite: BulkWriteSupport{
@@ -713,7 +725,7 @@ var catalog = CatalogType{ // nolint:gochecknoglobals
 				Upsert: false,
 				Delete: false,
 			},
-			Proxy:     false,
+			Proxy:     true,
 			Read:      false,
 			Subscribe: false,
 			Write:     false,
@@ -738,7 +750,7 @@ var catalog = CatalogType{ // nolint:gochecknoglobals
 				Upsert: false,
 				Delete: false,
 			},
-			Proxy:     false,
+			Proxy:     true,
 			Read:      false,
 			Subscribe: false,
 			Write:     false,
@@ -801,7 +813,7 @@ var catalog = CatalogType{ // nolint:gochecknoglobals
 			AuthURL:                   "https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
 			TokenURL:                  "https://login.microsoftonline.com/common/oauth2/v2.0/token",
 			ExplicitScopesRequired:    true,
-			ExplicitWorkspaceRequired: true,
+			ExplicitWorkspaceRequired: false,
 		},
 		Support: Support{
 			BulkWrite: BulkWriteSupport{
@@ -897,6 +909,7 @@ var catalog = CatalogType{ // nolint:gochecknoglobals
 		AuthType: Oauth2,
 		BaseURL:  "https://api.box.com",
 		OauthOpts: OauthOpts{
+			GrantType:                 AuthorizationCode,
 			AuthURL:                   "https://account.box.com/api/oauth2/authorize",
 			TokenURL:                  "https://api.box.com/oauth2/token",
 			ExplicitScopesRequired:    false,
@@ -909,7 +922,7 @@ var catalog = CatalogType{ // nolint:gochecknoglobals
 				Upsert: false,
 				Delete: false,
 			},
-			Proxy:     false,
+			Proxy:     true,
 			Read:      false,
 			Subscribe: false,
 			Write:     false,
@@ -918,8 +931,9 @@ var catalog = CatalogType{ // nolint:gochecknoglobals
 
 	// Zendesk Support configuration
 	ZendeskSupport: {
-		AuthType: Oauth2,
-		BaseURL:  "https://{{.workspace}}.zendesk.com",
+		DisplayName: "Zendesk Support",
+		AuthType:    Oauth2,
+		BaseURL:     "https://{{.workspace}}.zendesk.com",
 		OauthOpts: OauthOpts{
 			GrantType:                 AuthorizationCode,
 			AuthURL:                   "https://{{.workspace}}.zendesk.com/oauth/authorizations/new",
@@ -942,8 +956,9 @@ var catalog = CatalogType{ // nolint:gochecknoglobals
 	},
 
 	ZendeskChat: {
-		AuthType: Oauth2,
-		BaseURL:  "https://www.zopim.com",
+		DisplayName: "Zendesk Chat",
+		AuthType:    Oauth2,
+		BaseURL:     "https://www.zopim.com",
 		OauthOpts: OauthOpts{
 			GrantType:                 AuthorizationCode,
 			AuthURL:                   "https://www.zopim.com/oauth2/authorizations/new?subdomain={{.workspace}}",
@@ -1017,6 +1032,34 @@ var catalog = CatalogType{ // nolint:gochecknoglobals
 		},
 	},
 
+	// Ironclad Support Configuration
+	Ironclad: {
+		AuthType: Oauth2,
+		BaseURL:  "https://ironcladapp.com",
+		OauthOpts: OauthOpts{
+			AuthURL:                   "https://ironcladapp.com/oauth/authorize",
+			TokenURL:                  "https://ironcladapp.com/oauth/token",
+			ExplicitScopesRequired:    true,
+			ExplicitWorkspaceRequired: false,
+			GrantType:                 AuthorizationCode,
+			TokenMetadataFields: TokenMetadataFields{
+				ScopesField: "scope",
+			},
+		},
+		Support: Support{
+			BulkWrite: BulkWriteSupport{
+				Insert: false,
+				Update: false,
+				Upsert: false,
+				Delete: false,
+			},
+			Proxy:     false,
+			Read:      false,
+			Subscribe: false,
+			Write:     false,
+		},
+	},
+
 	// Slack configuration
 	Slack: {
 		AuthType: Oauth2,
@@ -1026,7 +1069,7 @@ var catalog = CatalogType{ // nolint:gochecknoglobals
 			AuthURL:                   "https://slack.com/oauth/v2/authorize",
 			TokenURL:                  "https://slack.com/api/oauth.v2.access",
 			ExplicitScopesRequired:    true,
-			ExplicitWorkspaceRequired: true,
+			ExplicitWorkspaceRequired: false,
 			TokenMetadataFields: TokenMetadataFields{
 				ScopesField:       "scope",
 				WorkspaceRefField: "workspace_name",
@@ -1039,7 +1082,7 @@ var catalog = CatalogType{ // nolint:gochecknoglobals
 				Upsert: false,
 				Delete: false,
 			},
-			Proxy:     false,
+			Proxy:     true,
 			Read:      false,
 			Subscribe: false,
 			Write:     false,
@@ -1264,7 +1307,7 @@ var catalog = CatalogType{ // nolint:gochecknoglobals
 	},
 
 	// GoogleMail Support Configuration
-	GoogleMail: {
+	Gmail: {
 		AuthType: Oauth2,
 		BaseURL:  "https://gmail.googleapis.com",
 		OauthOpts: OauthOpts{
@@ -1400,13 +1443,13 @@ var catalog = CatalogType{ // nolint:gochecknoglobals
 	// Zuora Configuration
 	Zuora: {
 		AuthType: Oauth2,
-		BaseURL:  "https://{{.subdomain}}.zuora.com",
+		BaseURL:  "https://{{.workspace}}.zuora.com",
 		OauthOpts: OauthOpts{
 			GrantType:                 ClientCredentials,
-			AuthURL:                   "https://{{.subdomain}}.zuora.com/oauth/auth_mock",
-			TokenURL:                  "https://{{.subdomain}}.zuora.com/oauth/token",
+			AuthURL:                   "https://{{.workspace}}.zuora.com/oauth/auth_mock",
+			TokenURL:                  "https://{{.workspace}}.zuora.com/oauth/token",
 			ExplicitScopesRequired:    false,
-			ExplicitWorkspaceRequired: false,
+			ExplicitWorkspaceRequired: true,
 		},
 		Support: Support{
 			BulkWrite: BulkWriteSupport{
@@ -1446,4 +1489,138 @@ var catalog = CatalogType{ // nolint:gochecknoglobals
 			Write:     false,
 		},
 	},
+	// ClickUp Support Configuration
+	ClickUp: {
+		AuthType: Oauth2,
+		BaseURL:  "https://api.clickup.com",
+		OauthOpts: OauthOpts{
+			GrantType:                 AuthorizationCode,
+			AuthURL:                   "https://app.clickup.com/api",
+			TokenURL:                  "https://api.clickup.com/api/v2/oauth/token",
+			ExplicitScopesRequired:    false,
+			ExplicitWorkspaceRequired: false,
+		},
+		Support: Support{
+			BulkWrite: BulkWriteSupport{
+				Insert: false,
+				Update: false,
+				Upsert: false,
+				Delete: false,
+			},
+			Proxy:     false,
+			Read:      false,
+			Subscribe: false,
+			Write:     false,
+		},
+	},
+
+	// Discord Support Configuration
+	Discord: {
+		AuthType: Oauth2,
+		BaseURL:  "https://discord.com",
+		OauthOpts: OauthOpts{
+			GrantType:                 AuthorizationCode,
+			AuthURL:                   "https://discord.com/oauth2/authorize",
+			TokenURL:                  "https://discord.com/api/oauth2/token",
+			ExplicitScopesRequired:    true,
+			ExplicitWorkspaceRequired: false,
+			TokenMetadataFields: TokenMetadataFields{
+				ScopesField: "scope",
+			},
+		},
+		Support: Support{
+			BulkWrite: BulkWriteSupport{
+				Insert: false,
+				Update: false,
+				Upsert: false,
+				Delete: false,
+			},
+			Proxy:     false,
+			Read:      false,
+			Subscribe: false,
+			Write:     false,
+		},
+	},
+
+	IroncladDemo: {
+		AuthType: Oauth2,
+		BaseURL:  "https://demo.ironcladapp.com",
+		OauthOpts: OauthOpts{
+			AuthURL:                   "https://demo.ironcladapp.com/oauth/authorize",
+			TokenURL:                  "https://demo.ironcladapp.com/oauth/token",
+			ExplicitScopesRequired:    true,
+			ExplicitWorkspaceRequired: false,
+			GrantType:                 AuthorizationCode,
+			TokenMetadataFields: TokenMetadataFields{
+				ScopesField: "scope",
+			},
+		},
+		Support: Support{
+			BulkWrite: BulkWriteSupport{
+				Insert: false,
+				Update: false,
+				Upsert: false,
+				Delete: false,
+			},
+			Proxy:     false,
+			Read:      false,
+			Subscribe: false,
+			Write:     false,
+		},
+	},
+
+	IroncladEU: {
+		AuthType: Oauth2,
+		BaseURL:  "https://eu1.ironcladapp.com",
+		OauthOpts: OauthOpts{
+			AuthURL:                   "https://eu1.ironcladapp.com/oauth/authorize",
+			TokenURL:                  "https://eu1.ironcladapp.com/oauth/token",
+			ExplicitScopesRequired:    true,
+			ExplicitWorkspaceRequired: false,
+			GrantType:                 AuthorizationCode,
+			TokenMetadataFields: TokenMetadataFields{
+				ScopesField: "scope",
+			},
+		},
+		Support: Support{
+			BulkWrite: BulkWriteSupport{
+				Insert: false,
+				Update: false,
+				Upsert: false,
+				Delete: false,
+			},
+			Proxy:     false,
+			Read:      false,
+			Subscribe: false,
+			Write:     false,
+		},
+	},
+
+	// Aircall Configuration
+	Aircall: {
+		AuthType: Oauth2,
+		BaseURL:  "https://api.aircall.io",
+		OauthOpts: OauthOpts{
+			GrantType:                 AuthorizationCode,
+			AuthURL:                   "https://dashboard.aircall.io/oauth/authorize",
+			TokenURL:                  "https://api.aircall.io/v1/oauth/token",
+			ExplicitScopesRequired:    true,
+			ExplicitWorkspaceRequired: false,
+			TokenMetadataFields: TokenMetadataFields{
+				ScopesField: "scope",
+			},
+		},
+		Support: Support{
+			BulkWrite: BulkWriteSupport{
+				Insert: false,
+				Update: false,
+				Upsert: false,
+				Delete: false,
+			},
+			Proxy:     false,
+			Read:      false,
+			Subscribe: false,
+			Write:     false,
+		},
+	},
 }
diff --git a/providers/types.gen.go b/providers/types.gen.go
index bdbb7b07..a5408bca 100644
--- a/providers/types.gen.go
+++ b/providers/types.gen.go
@@ -48,8 +48,11 @@ type Provider = string
 
 // ProviderInfo defines model for ProviderInfo.
 type ProviderInfo struct {
-	AuthType     AuthType     `json:"authType" validate:"required"`
-	BaseURL      string       `json:"baseURL" validate:"required"`
+	AuthType AuthType `json:"authType" validate:"required"`
+	BaseURL  string   `json:"baseURL" validate:"required"`
+
+	// DisplayName The display name of the provider, if omitted, defaults to provider name.
+	DisplayName  string       `json:"displayName,omitempty"`
 	OauthOpts    OauthOpts    `json:"oauthOpts" validate:"required"`
 	ProviderOpts ProviderOpts `json:"providerOpts"`
 	Support      Support      `json:"support" validate:"required"`
diff --git a/providers/types.yaml b/providers/types.yaml
index b3fe8e31..a345fe05 100644
--- a/providers/types.yaml
+++ b/providers/types.yaml
@@ -139,7 +139,11 @@ components:
           $ref: '#/components/schemas/Support'
         providerOpts:
           $ref: '#/components/schemas/ProviderOpts'
-
+        displayName:
+          type: string
+          example: Zendesk Chat
+          description: The display name of the provider, if omitted, defaults to provider name.
+          x-go-type-skip-optional-pointer: true
     CatalogType:
       type: object
       additionalProperties:
diff --git a/salesloft/client.go b/salesloft/client.go
new file mode 100644
index 00000000..b57ce1f0
--- /dev/null
+++ b/salesloft/client.go
@@ -0,0 +1,16 @@
+package salesloft
+
+import "github.com/amp-labs/connectors/common"
+
+// JSONHTTPClient returns the underlying JSON HTTP client.
+func (c *Connector) JSONHTTPClient() *common.JSONHTTPClient {
+	return c.Client
+}
+
+func (c *Connector) HTTPClient() *common.HTTPClient {
+	return c.Client.HTTPClient
+}
+
+func (c *Connector) Close() error {
+	return nil
+}
diff --git a/salesloft/connector.go b/salesloft/connector.go
new file mode 100644
index 00000000..d3fedc59
--- /dev/null
+++ b/salesloft/connector.go
@@ -0,0 +1,80 @@
+package salesloft
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/interpreter"
+	"github.com/amp-labs/connectors/common/paramsbuilder"
+	"github.com/amp-labs/connectors/common/urlbuilder"
+	"github.com/amp-labs/connectors/providers"
+)
+
+var DefaultModuleCRM = paramsbuilder.APIModule{ // nolint: gochecknoglobals
+	Version: "v2",
+}
+
+type Connector struct {
+	BaseURL string
+	Module  string
+	Client  *common.JSONHTTPClient
+}
+
+func NewConnector(opts ...Option) (conn *Connector, outErr error) {
+	defer common.PanicRecovery(func(cause error) {
+		outErr = cause
+		conn = nil
+	})
+
+	params, err := parameters{}.FromOptions(opts...)
+	if err != nil {
+		return nil, err
+	}
+
+	providerInfo, err := providers.ReadInfo(providers.Salesloft, nil)
+	if err != nil {
+		return nil, err
+	}
+
+	httpClient := params.Client.Caller
+	conn = &Connector{
+		Module: params.Module.Suffix,
+		Client: &common.JSONHTTPClient{
+			HTTPClient: httpClient,
+		},
+	}
+	// connector and its client must mirror base url and provide its own error parser
+	conn.setBaseURL(providerInfo.BaseURL)
+	conn.Client.HTTPClient.ErrorHandler = interpreter.ErrorHandler{
+		JSON: conn.interpretJSONError,
+	}.Handle
+
+	return conn, nil
+}
+
+func (c *Connector) Provider() providers.Provider {
+	return providers.Salesloft
+}
+
+func (c *Connector) String() string {
+	return fmt.Sprintf("%s.Connector[%s]", c.Provider(), c.Module)
+}
+
+func (c *Connector) getURL(arg string) (*urlbuilder.URL, error) {
+	parts := []string{c.BaseURL, c.Module, arg}
+	filtered := make([]string, 0)
+
+	for _, part := range parts {
+		if len(part) != 0 {
+			filtered = append(filtered, part)
+		}
+	}
+
+	return urlbuilder.New(strings.Join(filtered, "/"))
+}
+
+func (c *Connector) setBaseURL(newURL string) {
+	c.BaseURL = newURL
+	c.Client.HTTPClient.Base = newURL
+}
diff --git a/salesloft/delete.go b/salesloft/delete.go
new file mode 100644
index 00000000..f9a6772d
--- /dev/null
+++ b/salesloft/delete.go
@@ -0,0 +1,34 @@
+package salesloft
+
+import (
+	"context"
+
+	"github.com/amp-labs/connectors/common"
+)
+
+func (c *Connector) Delete(ctx context.Context, config common.DeleteParams) (*common.DeleteResult, error) {
+	if len(config.ObjectName) == 0 {
+		return nil, common.ErrMissingObjects
+	}
+
+	if len(config.RecordId) == 0 {
+		return nil, common.ErrMissingRecordID
+	}
+
+	url, err := c.getURL(config.ObjectName)
+	if err != nil {
+		return nil, err
+	}
+
+	url.AddPath(config.RecordId)
+
+	// 204 NoContent is expected
+	_, err = c.delete(ctx, url.String())
+	if err != nil {
+		return nil, err
+	}
+
+	return &common.DeleteResult{
+		Success: true,
+	}, nil
+}
diff --git a/salesloft/errors.go b/salesloft/errors.go
new file mode 100644
index 00000000..4008b791
--- /dev/null
+++ b/salesloft/errors.go
@@ -0,0 +1,73 @@
+package salesloft
+
+import (
+	"encoding/json"
+	"fmt"
+	"net/http"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/interpreter"
+)
+
+func (*Connector) interpretJSONError(res *http.Response, body []byte) error { //nolint:cyclop
+	payload := make(map[string]any)
+	if err := json.Unmarshal(body, &payload); err != nil {
+		return fmt.Errorf("interpretJSONError general: %w %w", interpreter.ErrUnmarshal, err)
+	}
+
+	// now we can choose which error response Schema we expect
+	var schema errorDescriptor
+
+	if _, ok := payload["errors"]; ok {
+		apiError := &ResponseListError{}
+		if err := json.Unmarshal(body, &apiError); err != nil {
+			return fmt.Errorf("interpretJSONError ListError: %w %w", interpreter.ErrUnmarshal, err)
+		}
+
+		schema = apiError
+		if res.StatusCode == http.StatusUnprocessableEntity {
+			return schema.combineErr(common.ErrBadRequest)
+		}
+	} else {
+		// default to simple response
+		apiError := &ResponseSingleError{}
+		if err := json.Unmarshal(body, &apiError); err != nil {
+			return fmt.Errorf("interpretJSONError SingleError: %w %w", interpreter.ErrUnmarshal, err)
+		}
+
+		schema = apiError
+	}
+
+	// enhance status code error with response payload
+	return schema.combineErr(interpreter.DefaultStatusCodeMappingToErr(res, body))
+}
+
+type errorDescriptor interface {
+	combineErr(base error) error
+}
+
+type ResponseListError struct {
+	Errors map[string]any `json:"errors"`
+}
+
+func (r ResponseListError) combineErr(base error) error {
+	var message string
+
+	data, err := json.Marshal(r.Errors)
+	if err != nil {
+		message = "failed parsing error response"
+	} else {
+		message = string(data)
+	}
+
+	return fmt.Errorf("%w: %s", base, message)
+}
+
+type ResponseSingleError struct {
+	Status int64  `json:"status"`
+	Err    string `json:"error"`
+}
+
+func (r ResponseSingleError) combineErr(base error) error {
+	return fmt.Errorf("%w: %s", base, r.Err)
+}
diff --git a/salesloft/methods.go b/salesloft/methods.go
new file mode 100644
index 00000000..d4f63af7
--- /dev/null
+++ b/salesloft/methods.go
@@ -0,0 +1,70 @@
+package salesloft
+
+import (
+	"context"
+	"errors"
+	"log/slog"
+
+	"github.com/amp-labs/connectors/common"
+)
+
+// TODO these methods look repetitive.
+func (c *Connector) get(ctx context.Context,
+	url string, headers ...common.Header,
+) (*common.JSONHTTPResponse, error) {
+	rsp, err := c.Client.Get(ctx, url, headers...)
+	if err = handleError(err); err != nil {
+		return nil, err
+	}
+
+	return rsp, nil
+}
+
+func (c *Connector) post(ctx context.Context, url string, body any) (*common.JSONHTTPResponse, error) {
+	rsp, err := c.Client.Post(ctx, url, body)
+	if err = handleError(err); err != nil {
+		return nil, err
+	}
+
+	return rsp, nil
+}
+
+func (c *Connector) put(ctx context.Context, url string, body any) (*common.JSONHTTPResponse, error) {
+	rsp, err := c.Client.Put(ctx, url, body)
+	if err = handleError(err); err != nil {
+		return nil, err
+	}
+
+	return rsp, nil
+}
+
+func (c *Connector) delete(ctx context.Context, url string) (*common.JSONHTTPResponse, error) {
+	rsp, err := c.Client.Delete(ctx, url)
+	if err = handleError(err); err != nil {
+		return nil, err
+	}
+
+	return rsp, nil
+}
+
+func handleError(err error) error {
+	if err == nil {
+		return nil
+	}
+
+	switch {
+	case errors.Is(err, common.ErrAccessToken):
+		slog.Warn("Access token invalid, retrying", "error", err)
+
+		fallthrough
+	case errors.Is(err, common.ErrRetryable):
+		fallthrough
+	case errors.Is(err, common.ErrApiDisabled):
+		fallthrough
+	case errors.Is(err, common.ErrForbidden):
+		fallthrough
+	default:
+		// Anything else is a permanent error
+		return err
+	}
+}
diff --git a/salesloft/params.go b/salesloft/params.go
new file mode 100644
index 00000000..4069b009
--- /dev/null
+++ b/salesloft/params.go
@@ -0,0 +1,61 @@
+package salesloft
+
+import (
+	"context"
+	"errors"
+	"net/http"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/paramsbuilder"
+	"golang.org/x/oauth2"
+)
+
+const (
+	// DefaultPageSize is number of elements per page.
+	DefaultPageSize = 100
+)
+
+// Option is a function which mutates the connector configuration.
+type Option func(params *parameters)
+
+// parameters Salesloft supports auth client, workspace, etc. by delegation.
+type parameters struct {
+	paramsbuilder.Client
+	paramsbuilder.Module
+}
+
+func (p parameters) FromOptions(opts ...Option) (*parameters, error) {
+	params := &p
+	for _, opt := range opts {
+		opt(params)
+	}
+
+	return params, params.ValidateParams()
+}
+
+func (p parameters) ValidateParams() error {
+	return errors.Join(
+		p.Client.ValidateParams(),
+		p.Module.ValidateParams(),
+	)
+}
+
+func WithClient(ctx context.Context, client *http.Client,
+	config *oauth2.Config, token *oauth2.Token, opts ...common.OAuthOption,
+) Option {
+	return func(params *parameters) {
+		params.WithClient(ctx, client, config, token, opts...)
+	}
+}
+
+func WithAuthenticatedClient(client common.AuthenticatedHTTPClient) Option {
+	return func(params *parameters) {
+		params.WithAuthenticatedClient(client)
+	}
+}
+
+func WithModule(module paramsbuilder.APIModule) Option {
+	return func(params *parameters) {
+		params.WithModule(module)
+	}
+}
diff --git a/salesloft/parse.go b/salesloft/parse.go
new file mode 100644
index 00000000..254ccbdb
--- /dev/null
+++ b/salesloft/parse.go
@@ -0,0 +1,81 @@
+package salesloft
+
+import (
+	"errors"
+	"strconv"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/jsonquery"
+	"github.com/amp-labs/connectors/common/urlbuilder"
+	"github.com/spyzhov/ajson"
+)
+
+/*
+Response example:
+
+	{
+	  "metadata": {
+	    "filtering": {},
+	    "paging": {
+	      "per_page": 25,
+	      "current_page": 1,
+	      "next_page": 2,
+	      "prev_page": null
+	    },
+	    "sorting": {
+	      "sort_by": "updated_at",
+	      "sort_direction": "DESC NULLS LAST"
+	    }
+	  },
+	  "data": [...]
+	}
+*/
+func getTotalSize(node *ajson.Node) (int64, error) {
+	return jsonquery.New(node).ArraySize("data")
+}
+
+func getRecords(node *ajson.Node) ([]map[string]any, error) {
+	arr, err := jsonquery.New(node).Array("data", false)
+	if err != nil {
+		return nil, err
+	}
+
+	return jsonquery.Convertor.ArrayToMap(arr)
+}
+
+func makeNextRecordsURL(reqLink *urlbuilder.URL) common.NextPageFunc {
+	return func(node *ajson.Node) (string, error) {
+		nextPageNum, err := jsonquery.New(node, "metadata", "paging").Integer("next_page", true)
+		if err != nil {
+			if errors.Is(err, jsonquery.ErrKeyNotFound) {
+				// list resource doesn't support pagination, hence no next page
+				return "", nil
+			}
+
+			return "", err
+		}
+
+		if nextPageNum == nil {
+			// next page doesn't exist
+			return "", nil
+		}
+
+		// use request URL to infer the next page URL
+		reqLink.WithQueryParam("page", strconv.FormatInt(*nextPageNum, 10))
+
+		return reqLink.String(), nil
+	}
+}
+
+func getMarshaledData(records []map[string]interface{}, fields []string) ([]common.ReadResultRow, error) {
+	data := make([]common.ReadResultRow, len(records))
+
+	for i, record := range records {
+		data[i] = common.ReadResultRow{
+			Fields: common.ExtractLowercaseFieldsFromRaw(fields, record),
+			Raw:    record,
+		}
+	}
+
+	return data, nil
+}
diff --git a/salesloft/read.go b/salesloft/read.go
new file mode 100644
index 00000000..a260a945
--- /dev/null
+++ b/salesloft/read.go
@@ -0,0 +1,47 @@
+package salesloft
+
+import (
+	"context"
+	"strconv"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/urlbuilder"
+)
+
+func (c *Connector) Read(ctx context.Context, config common.ReadParams) (*common.ReadResult, error) {
+	link, err := c.buildReadURL(config)
+	if err != nil {
+		return nil, err
+	}
+
+	rsp, err := c.get(ctx, link.String())
+	if err != nil {
+		return nil, err
+	}
+
+	return common.ParseResult(
+		rsp,
+		getTotalSize,
+		getRecords,
+		makeNextRecordsURL(link),
+		getMarshaledData,
+		config.Fields,
+	)
+}
+
+func (c *Connector) buildReadURL(config common.ReadParams) (*urlbuilder.URL, error) {
+	if len(config.NextPage) != 0 {
+		// Next page
+		return urlbuilder.New(config.NextPage.String())
+	}
+
+	// First page
+	link, err := c.getURL(config.ObjectName)
+	if err != nil {
+		return nil, err
+	}
+
+	link.WithQueryParam("per_page", strconv.Itoa(DefaultPageSize))
+
+	return link, nil
+}
diff --git a/salesloft/write.go b/salesloft/write.go
new file mode 100644
index 00000000..50321676
--- /dev/null
+++ b/salesloft/write.go
@@ -0,0 +1,80 @@
+package salesloft
+
+import (
+	"context"
+	"strconv"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/jsonquery"
+	"github.com/spyzhov/ajson"
+)
+
+type writeMethod func(context.Context, string, any) (*common.JSONHTTPResponse, error)
+
+func (c *Connector) Write(ctx context.Context, config common.WriteParams) (*common.WriteResult, error) {
+	if len(config.ObjectName) == 0 {
+		return nil, common.ErrMissingObjects
+	}
+
+	url, err := c.getURL(config.ObjectName)
+	if err != nil {
+		return nil, err
+	}
+
+	var write writeMethod
+	if len(config.RecordId) == 0 {
+		// writing to the entity without id means
+		// that we are extending 'List' resource and creating a new record
+		write = c.post
+	} else {
+		// only put is supported for updating 'Single' resource
+		write = c.put
+
+		url.AddPath(config.RecordId)
+	}
+
+	res, err := write(ctx, url.String(), config.RecordData)
+	if err != nil {
+		return nil, err
+	}
+
+	if res == nil || res.Body == nil {
+		// it is unlikely to have no payload
+		return &common.WriteResult{
+			Success: true,
+		}, nil
+	}
+
+	// write response was with payload
+	return constructWriteResult(res.Body)
+}
+
+func constructWriteResult(body *ajson.Node) (*common.WriteResult, error) {
+	nested, err := jsonquery.New(body).Object("data", false)
+	if err != nil {
+		return nil, err
+	}
+
+	rawID, err := jsonquery.New(nested).Integer("id", true)
+	if err != nil {
+		return nil, err
+	}
+
+	recordID := ""
+	if rawID != nil {
+		// optional
+		recordID = strconv.FormatInt(*rawID, 10)
+	}
+
+	data, err := jsonquery.Convertor.ObjectToMap(nested)
+	if err != nil {
+		return nil, err
+	}
+
+	return &common.WriteResult{
+		Success:  true,
+		RecordId: recordID,
+		Errors:   nil,
+		Data:     data,
+	}, nil
+}
diff --git a/utils/credentials.go b/utils/credentials.go
index 8f4f5eb4..09e5f332 100644
--- a/utils/credentials.go
+++ b/utils/credentials.go
@@ -128,6 +128,39 @@ func MSDynamics365CRMTokenFromRegistry(registry CredentialsRegistry) *oauth2.Tok
 	return tok
 }
 
+func SalesloftConfigFromRegistry(registry CredentialsRegistry) *oauth2.Config {
+	clientId := registry.MustString(ClientId)
+	clientSecret := registry.MustString(ClientSecret)
+
+	cfg := &oauth2.Config{
+		ClientID:     clientId,
+		ClientSecret: clientSecret,
+		RedirectURL:  "http://localhost:8080/callbacks/v1/oauth",
+		Endpoint: oauth2.Endpoint{
+			AuthURL:   "https://accounts.salesloft.com/oauth/authorize",
+			TokenURL:  "https://accounts.salesloft.com/oauth/token",
+			AuthStyle: oauth2.AuthStyleInParams,
+		},
+		Scopes: []string{},
+	}
+
+	return cfg
+}
+
+func SalesloftTokenFromRegistry(registry CredentialsRegistry) *oauth2.Token {
+	accessToken := registry.MustString(AccessToken)
+	refreshToken := registry.MustString(RefreshToken)
+
+	tok := &oauth2.Token{
+		AccessToken:  accessToken,
+		RefreshToken: refreshToken,
+		TokenType:    "bearer",
+		Expiry:       time.Now().Add(-1 * time.Hour), // just pretend it's expired already, whatever, it'll fetch a new one.
+	}
+
+	return tok
+}
+
 func OutreachOAuthConfigFromRegistry(registry CredentialsRegistry) *oauth2.Config {
 	clientId := registry.MustString(ClientId)
 	clientSecret := registry.MustString(ClientSecret)


Test patch:
diff --git a/common/jsonquery/queries_test.go b/common/jsonquery/queries_test.go
new file mode 100644
index 00000000..355cc931
--- /dev/null
+++ b/common/jsonquery/queries_test.go
@@ -0,0 +1,411 @@
+package jsonquery
+
+import (
+	"errors"
+	"reflect"
+	"testing"
+
+	"github.com/spyzhov/ajson"
+)
+
+// nolint:gochecknoglobals
+var testJSONData = `{
+		"count":38, "text":"Hello World", "pi":3.14, "metadata":null, "list":[1,2,3], "arr":[],
+		"payload": {
+			"notes": {
+				"links": null,
+				"body": {
+					"text": "Some notes",
+					"amount": 359
+				},
+				"nested_arr": [1,2,3,4,5]
+			},
+			"display_time": "yesterday"
+		}}`
+
+func TestQuery_Integer(t *testing.T) { // nolint:funlen
+	t.Parallel()
+
+	j := helperCreateJSON(t, testJSONData) // nolint:varnamelen
+
+	type inType struct {
+		key      string
+		optional bool
+		zoom     []string
+	}
+
+	tests := []struct {
+		name        string
+		input       inType
+		expected    *int64
+		expectedErr error
+	}{
+		{
+			name: "Missing required integer",
+			input: inType{
+				key:      "age",
+				optional: false,
+			},
+			expectedErr: ErrKeyNotFound,
+		},
+		{
+			name: "Missing optional integer",
+			input: inType{
+				key:      "age",
+				optional: true,
+			},
+			expected: nil,
+		},
+		{
+			name: "Incorrect data type",
+			input: inType{
+				key: "text",
+			},
+			expectedErr: ErrNotNumeric,
+		},
+		{
+			name: "Float is not integer",
+			input: inType{
+				key: "pi",
+			},
+			expectedErr: ErrNotInteger,
+		},
+		{
+			name: "Key is found but Null",
+			input: inType{
+				key: "metadata",
+			},
+			expectedErr: ErrNullJSON,
+		},
+		{
+			name: "Valid number",
+			input: inType{
+				key: "count",
+			},
+			expected: ptrInt64(38),
+		},
+		{
+			name: "Reaching for nested integer",
+			input: inType{
+				key:      "amount",
+				optional: false,
+				zoom:     []string{"payload", "notes", "body"},
+			},
+			expected: ptrInt64(359),
+		},
+		{
+			name: "Non existent zoom path is ok for optional integer",
+			input: inType{
+				key:      "street",
+				optional: true,
+				zoom:     []string{"payload", "location", "address"},
+			},
+			expected: nil,
+		},
+	}
+
+	for _, tt := range tests {
+		// nolint:varnamelen
+		tt := tt // rebind, omit loop side effects for parallel goroutine
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
+			output, err := New(j, tt.input.zoom...).Integer(tt.input.key, tt.input.optional)
+			assertJSONManagerOutput(t, tt.name, tt.expected, tt.expectedErr, output, err)
+		})
+	}
+}
+
+func TestQuery_String(t *testing.T) { // nolint:funlen
+	t.Parallel()
+
+	j := helperCreateJSON(t, testJSONData) // nolint:varnamelen
+
+	type inType struct {
+		key      string
+		optional bool
+		zoom     []string
+	}
+
+	tests := []struct {
+		name        string
+		input       inType
+		expected    *string
+		expectedErr error
+	}{
+		{
+			name: "Missing required string",
+			input: inType{
+				key:      "surname",
+				optional: false,
+			},
+			expectedErr: ErrKeyNotFound,
+		},
+		{
+			name: "Missing optional string",
+			input: inType{
+				key:      "surname",
+				optional: true,
+			},
+			expected: nil,
+		},
+		{
+			name: "Incorrect data type",
+			input: inType{
+				key: "count",
+			},
+			expectedErr: ErrNotString,
+		},
+		{
+			name: "Key is found but Null",
+			input: inType{
+				key: "metadata",
+			},
+			expectedErr: ErrNullJSON,
+		},
+		{
+			name: "Valid string",
+			input: inType{
+				key: "text",
+			},
+			expected: ptrString("Hello World"),
+		},
+		{
+			name: "Reaching for nested string",
+			input: inType{
+				key:      "text",
+				optional: false,
+				zoom:     []string{"payload", "notes", "body"},
+			},
+			expected: ptrString("Hello World"),
+		},
+		{
+			name: "Non existent zoom path is ok for optional string",
+			input: inType{
+				key:      "street",
+				optional: true,
+				zoom:     []string{"payload", "location", "address"},
+			},
+			expected: nil,
+		},
+	}
+
+	for _, tt := range tests {
+		// nolint:varnamelen
+		tt := tt // rebind, omit loop side effects for parallel goroutine
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
+			output, err := New(j).Str(tt.input.key, tt.input.optional)
+			assertJSONManagerOutput(t, tt.name, tt.expected, tt.expectedErr, output, err)
+		})
+	}
+}
+
+func TestQuery_Array(t *testing.T) { // nolint:funlen
+	t.Parallel()
+
+	j := helperCreateJSON(t, testJSONData) // nolint:varnamelen
+
+	type inType struct {
+		key      string
+		optional bool
+		zoom     []string
+	}
+
+	tests := []struct {
+		name         string
+		input        inType
+		expectedSize int
+		expectedErr  error
+	}{
+		{
+			name: "Missing required array",
+			input: inType{
+				key:      "queue",
+				optional: false,
+			},
+			expectedSize: 0,
+			expectedErr:  ErrKeyNotFound,
+		},
+		{
+			name: "Key is found but Null",
+			input: inType{
+				key:      "metadata",
+				optional: false,
+			},
+			expectedErr: ErrNullJSON,
+		},
+		{
+			name: "Value with wrong type",
+			input: inType{
+				key:      "text",
+				optional: false,
+			},
+			expectedErr: ErrNotArray,
+		},
+		{
+			name: "Valid array",
+			input: inType{
+				key:      "list",
+				optional: false,
+			},
+			expectedSize: 3,
+		},
+		{
+			name: "Empty array",
+			input: inType{
+				key:      "arr",
+				optional: false,
+			},
+			expectedSize: 0,
+			expectedErr:  nil,
+		},
+		{
+			name: "Reaching for nested array",
+			input: inType{
+				key:      "nested_arr",
+				optional: false,
+				zoom:     []string{"payload", "notes"},
+			},
+			expectedSize: 5,
+		},
+		{
+			name: "Non existent zoom path is ok for optional arr",
+			input: inType{
+				key:      "street",
+				optional: true,
+				zoom:     []string{"payload", "location", "address"},
+			},
+			expectedSize: 0,
+			expectedErr:  nil,
+		},
+	}
+
+	for _, tt := range tests {
+		// nolint:varnamelen
+		tt := tt // rebind, omit loop side effects for parallel goroutine
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
+			output, err := New(j, tt.input.zoom...).Array(tt.input.key, tt.input.optional)
+
+			if !errors.Is(err, tt.expectedErr) {
+				t.Fatalf("%s: expected: (%v), got: (%v)", tt.name, tt.expectedErr, err)
+			}
+
+			outputSize := len(output)
+			if !reflect.DeepEqual(outputSize, tt.expectedSize) {
+				t.Fatalf("%s: expected: (%v), got: (%v)", tt.name, tt.expectedSize, outputSize)
+			}
+		})
+	}
+}
+
+func TestQuery_Object(t *testing.T) { // nolint:funlen
+	t.Parallel()
+
+	j := helperCreateJSON(t, testJSONData) // nolint:varnamelen
+
+	type inType struct {
+		key      string
+		optional bool
+		zoom     []string
+	}
+
+	tests := []struct {
+		name        string
+		input       inType
+		expectedErr error
+	}{
+		{
+			name: "Missing required nested object",
+			input: inType{
+				key:  "random",
+				zoom: []string{"payload"},
+			},
+			expectedErr: ErrKeyNotFound,
+		},
+		{
+			name: "Nested key is found but Null",
+			input: inType{
+				key:  "links",
+				zoom: []string{"payload", "notes"},
+			},
+			expectedErr: ErrNullJSON,
+		},
+		{
+			name: "Invalid data type of existing key",
+			input: inType{
+				key:  "text",
+				zoom: []string{"payload", "notes", "body"},
+			},
+			expectedErr: ErrNotObject,
+		},
+		{
+			name: "Valid nested object",
+			input: inType{
+				key:  "body",
+				zoom: []string{"payload", "notes"},
+			},
+			expectedErr: nil, // success
+		},
+		{
+			name: "Non existent zoom path is ok for optional object",
+			input: inType{
+				key:      "street",
+				optional: true,
+				zoom:     []string{"payload", "location", "address"},
+			},
+			expectedErr: nil, // success
+		},
+	}
+
+	for _, tt := range tests {
+		// nolint:varnamelen
+		tt := tt // rebind, omit loop side effects for parallel goroutine
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
+			_, err := New(j, tt.input.zoom...).Object(tt.input.key, tt.input.optional)
+
+			if !errors.Is(err, tt.expectedErr) {
+				t.Fatalf("%s: expected: (%v), got: (%v)", tt.name, tt.expectedErr, err)
+			}
+		})
+	}
+}
+
+func helperCreateJSON(t *testing.T, text string) *ajson.Node {
+	t.Helper()
+
+	jsonBody, err := ajson.Unmarshal([]byte(text))
+	if err != nil {
+		t.Fatalf("bad test, JSON object is invalid, cannot proceed with the test")
+	}
+
+	return jsonBody
+}
+
+func assertJSONManagerOutput(t *testing.T, name string, expected any, expectedErr error,
+	output any, err error,
+) {
+	t.Helper()
+
+	// check for actual error value
+	if !errors.Is(err, expectedErr) {
+		t.Fatalf("%s: expected: (%v), got: (%v)", name, expectedErr, err)
+	}
+
+	if !reflect.DeepEqual(output, expected) {
+		t.Fatalf("%s: expected: (%v), got: (%v)", name, expected, output)
+	}
+}
+
+func ptrInt64(num int64) *int64 {
+	return &num
+}
+
+func ptrString(text string) *string {
+	return &text
+}
diff --git a/common/urlbuilder/url_test.go b/common/urlbuilder/url_test.go
new file mode 100644
index 00000000..9488d936
--- /dev/null
+++ b/common/urlbuilder/url_test.go
@@ -0,0 +1,118 @@
+package urlbuilder
+
+import (
+	"errors"
+	"reflect"
+	"testing"
+)
+
+func TestNewURL(t *testing.T) { // nolint:funlen
+	t.Parallel()
+
+	tests := []struct {
+		name        string
+		input       string
+		expectedErr error
+	}{
+		{
+			name:  "URL without query params",
+			input: "http://video.google.co.uk:80/videoplay",
+		},
+		{
+			name:  "URL with one query",
+			input: "foo://example.com:8042/over/there?name=ferret",
+		},
+		{
+			name:  "URL with multiple queries and fragment",
+			input: "https://video.google.co.uk:80/videoplay?docid=-7246927612831078230&hl=en",
+		},
+		{
+			name:  "URL with fragment",
+			input: "https://video.google.co.uk:80/videoplay?docid=-7246927612831078230&hl=en#00h02m30s",
+		},
+	}
+
+	for _, tt := range tests {
+		// nolint:varnamelen
+		tt := tt // rebind, omit loop side effects for parallel goroutine
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
+			u, err := New(tt.input)
+			if !errors.Is(err, tt.expectedErr) {
+				t.Fatalf("%s: expected: (%v), got: (%v)", tt.name, tt.expectedErr, err)
+			}
+
+			output := u.String()
+
+			if !reflect.DeepEqual(output, tt.input) {
+				t.Fatalf("%s: expected: (%v), got: (%v)", tt.name, tt.input, output)
+			}
+		})
+	}
+}
+
+func TestWithQueryParam(t *testing.T) { // nolint:funlen
+	t.Parallel()
+
+	tests := []struct {
+		name     string
+		input    string
+		modifier func(*URL)
+		expected string
+	}{
+		{
+			name:  "Add one query parameter",
+			input: "https://video.google.co.uk:80/videoplay?docid=-7246927612831078230&hl=en",
+			modifier: func(u *URL) {
+				u.WithQueryParam("compact", "True")
+			},
+			expected: "https://video.google.co.uk:80/videoplay?compact=True&docid=-7246927612831078230&hl=en",
+		},
+		{
+			name:  "Add list query parameter",
+			input: "https://video.google.co.uk:80/videoplay?docid=-7246927612831078230&hl=en",
+			modifier: func(u *URL) {
+				u.WithQueryParamList("select", []string{"name", "address"})
+			},
+			expected: "https://video.google.co.uk:80/videoplay?docid=-7246927612831078230&hl=en&select=name&select=address",
+		},
+		{
+			name:  "Replace query parameter",
+			input: "https://video.google.co.uk:80/videoplay?docid=-7246927612831078230&hl=en",
+			modifier: func(u *URL) {
+				u.WithQueryParam("hl", "fr")
+			},
+			expected: "https://video.google.co.uk:80/videoplay?docid=-7246927612831078230&hl=fr",
+		},
+		{
+			name:  "Remove query parameter",
+			input: "https://video.google.co.uk:80/videoplay?docid=-7246927612831078230&hl=en",
+			modifier: func(u *URL) {
+				u.RemoveQueryParam("docid")
+			},
+			expected: "https://video.google.co.uk:80/videoplay?hl=en",
+		},
+	}
+
+	for _, tt := range tests {
+		// nolint:varnamelen
+		tt := tt // rebind, omit loop side effects for parallel goroutine
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
+			u, err := New(tt.input)
+			if err != nil {
+				t.Fatalf("bad test (%v)", tt.name)
+			}
+
+			// apply modifications from test scenario
+			tt.modifier(u)
+			output := u.String()
+
+			if !reflect.DeepEqual(output, tt.expected) {
+				t.Fatalf("%s: expected: (%v), got: (%v)", tt.name, tt.expected, output)
+			}
+		})
+	}
+}
diff --git a/microsoftdynamicscrm/delete_test.go b/microsoftdynamicscrm/delete_test.go
index 8edb9b38..b118627a 100644
--- a/microsoftdynamicscrm/delete_test.go
+++ b/microsoftdynamicscrm/delete_test.go
@@ -72,7 +72,7 @@ func TestDelete(t *testing.T) { // nolint:funlen,cyclop
 			input: common.DeleteParams{ObjectName: "fax", RecordId: "dd2f7870-3fe8-ee11-a204-0022481f9e3c"},
 			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 				w.Header().Set("Content-Type", "application/json")
-				mockutils.RespondWithMethodExpectation(w, r, "DELETE")
+				mockutils.RespondNoContentForMethod(w, r, "DELETE")
 			})),
 			expected:     &common.DeleteResult{Success: true},
 			expectedErrs: nil,
@@ -94,7 +94,7 @@ func TestDelete(t *testing.T) { // nolint:funlen,cyclop
 				WithWorkspace("test-workspace"),
 			)
 			if err != nil {
-				t.Fatalf("%s: error in test while constructin connector %v", tt.name, err)
+				t.Fatalf("%s: error in test while constructing connector %v", tt.name, err)
 			}
 
 			// for testing we want to redirect calls to our server
diff --git a/microsoftdynamicscrm/errors_test.go b/microsoftdynamicscrm/errors_test.go
index 2f2cebb6..83b76bca 100644
--- a/microsoftdynamicscrm/errors_test.go
+++ b/microsoftdynamicscrm/errors_test.go
@@ -42,6 +42,7 @@ func TestInterpretJSONError(t *testing.T) { //nolint:funlen
 		{
 			// TODO should it indicate that connector could handle it?
 			// TODO It would be bad to mask partial API implementation with actual caller error
+			// search for other test occurrences in the code base when resolving it
 			name: "Unknown response status produces caller error",
 			input: input{
 				res: &http.Response{
diff --git a/microsoftdynamicscrm/metadata_test.go b/microsoftdynamicscrm/metadata_test.go
index 63c1b346..4d3e80d5 100644
--- a/microsoftdynamicscrm/metadata_test.go
+++ b/microsoftdynamicscrm/metadata_test.go
@@ -18,10 +18,7 @@ import (
 func TestListObjectMetadata(t *testing.T) { // nolint:funlen,gocognit,cyclop
 	t.Parallel()
 
-	fakeServerResp, err := mockutils.DataFromFile("metadata.xml")
-	if err != nil {
-		t.Fatalf("failed to start test, input file missing, %v", err)
-	}
+	fakeServerResp := mockutils.DataFromFile(t, "metadata.xml")
 
 	tests := []struct {
 		name                string
@@ -162,7 +159,7 @@ func TestListObjectMetadata(t *testing.T) { // nolint:funlen,gocognit,cyclop
 				WithWorkspace("test-workspace"),
 			)
 			if err != nil {
-				t.Fatalf("%s: error in test while constructin connector %v", tt.name, err)
+				t.Fatalf("%s: error in test while constructing connector %v", tt.name, err)
 			}
 
 			// for testing we want to redirect calls to our mock server
diff --git a/microsoftdynamicscrm/read_test.go b/microsoftdynamicscrm/read_test.go
index 1e314035..c299b951 100644
--- a/microsoftdynamicscrm/read_test.go
+++ b/microsoftdynamicscrm/read_test.go
@@ -11,76 +11,23 @@ import (
 
 	"github.com/amp-labs/connectors/common"
 	"github.com/amp-labs/connectors/common/interpreter"
+	"github.com/amp-labs/connectors/common/jsonquery"
 	"github.com/amp-labs/connectors/test/utils/mockutils"
 	"github.com/go-test/deep"
-	"github.com/spyzhov/ajson"
 )
 
-func TestMakeQueryValues(t *testing.T) {
-	t.Parallel()
-
-	tests := []struct {
-		name     string
-		input    common.ReadParams
-		expected string
-	}{
-		{
-			name:     "No params no query string",
-			input:    common.ReadParams{},
-			expected: "",
-		},
-		{
-			name: "One parameter",
-			input: common.ReadParams{
-				Fields: []string{"cat"},
-			},
-			expected: "?$select=cat",
-		},
-		{
-			name: "Many parameters",
-			input: common.ReadParams{
-				Fields: []string{"cat", "dog", "parrot", "hamster"},
-			},
-			expected: "?$select=cat,dog,parrot,hamster",
-		},
-		{
-			name: "OData parameters with @ symbol",
-			input: common.ReadParams{
-				Fields: []string{"cat", "@odata.dog", "parrot"},
-			},
-			expected: "?$select=cat,@odata.dog,parrot",
-		},
-	}
-
-	for _, tt := range tests {
-		tt := tt // rebind, omit loop side effects for parallel goroutine
-		t.Run(tt.name, func(t *testing.T) {
-			t.Parallel()
-
-			output := makeQueryValues(tt.input)
-			if !reflect.DeepEqual(output, tt.expected) {
-				t.Fatalf("%s: expected: (%v), got: (%v)", tt.name, tt.expected, output)
-			}
-		})
-	}
-}
-
 func TestRead(t *testing.T) { //nolint:funlen,gocognit,cyclop
 	t.Parallel()
 
-	fakeServerResp, err := mockutils.DataFromFile("read.json")
-	if err != nil {
-		t.Fatalf("failed to start test, input file missing, %v", err)
-	}
+	fakeServerResp := mockutils.DataFromFile(t, "read.json")
 
 	tests := []struct {
-		name             string
-		input            common.ReadParams
-		server           *httptest.Server
-		connector        Connector
-		expected         *common.ReadResult
-		expectedErrs     []error
-		expectedErrTypes []error
+		name         string
+		input        common.ReadParams
+		server       *httptest.Server
+		connector    Connector
+		expected     *common.ReadResult
+		expectedErrs []error
 	}{
 		{
 			name: "Mime response header expected",
@@ -114,7 +61,7 @@ func TestRead(t *testing.T) { //nolint:funlen,gocognit,cyclop
 					"garbage": {}
 				}`)
 			})),
-			expectedErrTypes: []error{ajson.Error{}},
+			expectedErrs: []error{jsonquery.ErrKeyNotFound},
 		},
 		{
 			name: "Incorrect data type in payload",
@@ -125,7 +72,7 @@ func TestRead(t *testing.T) { //nolint:funlen,gocognit,cyclop
 					"value": {}
 				}`)
 			})),
-			expectedErrs: []error{common.ErrNotArray},
+			expectedErrs: []error{jsonquery.ErrNotArray},
 		},
 		{
 			name: "Next page cursor may be missing in payload",
@@ -242,7 +189,7 @@ func TestRead(t *testing.T) { //nolint:funlen,gocognit,cyclop
 				WithWorkspace("test-workspace"),
 			)
 			if err != nil {
-				t.Fatalf("%s: error in test while constructin connector %v", tt.name, err)
+				t.Fatalf("%s: error in test while constructing connector %v", tt.name, err)
 			}
 
 			// for testing we want to redirect calls to our mock server
@@ -251,7 +198,7 @@ func TestRead(t *testing.T) { //nolint:funlen,gocognit,cyclop
 			// start of tests
 			output, err := connector.Read(ctx, tt.input)
 			if err != nil {
-				if len(tt.expectedErrs)+len(tt.expectedErrTypes) == 0 {
+				if len(tt.expectedErrs) == 0 {
 					t.Fatalf("%s: expected no errors, got: (%v)", tt.name, err)
 				}
 			} else {
@@ -259,19 +206,9 @@ func TestRead(t *testing.T) { //nolint:funlen,gocognit,cyclop
 				if len(tt.expectedErrs) != 0 {
 					t.Fatalf("%s: expected errors (%v), but got nothing", tt.name, tt.expectedErrs)
 				}
-
-				if len(tt.expectedErrTypes) != 0 {
-					t.Fatalf("%s: expected error types (%v), but got nothing", tt.name, tt.expectedErrTypes)
-				}
 			}
 
 			// check every error
-			for _, expectedErr := range tt.expectedErrTypes {
-				if reflect.TypeOf(err) != reflect.TypeOf(expectedErr) {
-					t.Fatalf("%s: expected Error type: (%T), got: (%T)", tt.name, expectedErr, err)
-				}
-			}
-
 			for _, expectedErr := range tt.expectedErrs {
 				if !errors.Is(err, expectedErr) && !strings.Contains(err.Error(), expectedErr.Error()) {
 					t.Fatalf("%s: expected Error: (%v), got: (%v)", tt.name, expectedErr, err)
diff --git a/microsoftdynamicscrm/url_test.go b/microsoftdynamicscrm/url_test.go
new file mode 100644
index 00000000..c5d03382
--- /dev/null
+++ b/microsoftdynamicscrm/url_test.go
@@ -0,0 +1,68 @@
+package microsoftdynamicscrm
+
+import (
+	"fmt"
+	"reflect"
+	"strings"
+	"testing"
+
+	"github.com/amp-labs/connectors/common/urlbuilder"
+)
+
+func TestConstructURL(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name     string
+		input    *urlbuilder.URL
+		expected string
+	}{
+		{
+			name:     "No params no query string",
+			input:    createURLWithQuery("", nil),
+			expected: "https://test",
+		},
+		{
+			name:     "One parameter",
+			input:    createURLWithQuery("$select", []string{"cat"}),
+			expected: "https://test?$select=cat",
+		},
+		{
+			name:     "Many parameters",
+			input:    createURLWithQuery("$select", []string{"cat", "dog", "parrot", "hamster"}),
+			expected: "https://test?$select=cat,dog,parrot,hamster",
+		},
+		{
+			name:     "OData parameters with @ symbol",
+			input:    createURLWithQuery("$select", []string{"cat", "@odata.dog", "parrot"}),
+			expected: "https://test?$select=cat,@odata.dog,parrot",
+		},
+	}
+
+	for _, tt := range tests {
+		tt := tt // rebind, omit loop side effects for parallel goroutine
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
+			output := tt.input.String()
+			if !reflect.DeepEqual(output, tt.expected) {
+				t.Fatalf("%s: expected: (%v), got: (%v)", tt.name, tt.expected, output)
+			}
+		})
+	}
+}
+
+func createURLWithQuery(key string, values []string) *urlbuilder.URL {
+	value := strings.Join(values, ",")
+
+	link, err := constructURL("https://test")
+	if err != nil {
+		panic(fmt.Errorf("test is incorrect %w", err))
+	}
+
+	if len(key) != 0 {
+		link.WithQueryParam(key, value)
+	}
+
+	return link
+}
diff --git a/microsoftdynamicscrm/write_test.go b/microsoftdynamicscrm/write_test.go
index 07cb5d4d..d59edd52 100644
--- a/microsoftdynamicscrm/write_test.go
+++ b/microsoftdynamicscrm/write_test.go
@@ -64,7 +64,7 @@ func TestWrite(t *testing.T) { // nolint:funlen,cyclop
 			input: common.WriteParams{ObjectName: "fax"},
 			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 				w.Header().Set("Content-Type", "application/json")
-				mockutils.RespondWithMethodExpectation(w, r, "POST")
+				mockutils.RespondNoContentForMethod(w, r, "POST")
 			})),
 			expected:     &common.WriteResult{Success: true},
 			expectedErrs: nil,
@@ -74,7 +74,7 @@ func TestWrite(t *testing.T) { // nolint:funlen,cyclop
 			input: common.WriteParams{ObjectName: "fax", RecordId: "dd2f7870-3fe8-ee11-a204-0022481f9e3c"},
 			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 				w.Header().Set("Content-Type", "application/json")
-				mockutils.RespondWithMethodExpectation(w, r, "PATCH")
+				mockutils.RespondNoContentForMethod(w, r, "PATCH")
 			})),
 			expected:     &common.WriteResult{Success: true},
 			expectedErrs: nil,
@@ -96,7 +96,7 @@ func TestWrite(t *testing.T) { // nolint:funlen,cyclop
 				WithWorkspace("test-workspace"),
 			)
 			if err != nil {
-				t.Fatalf("%s: error in test while constructin connector %v", tt.name, err)
+				t.Fatalf("%s: error in test while constructing connector %v", tt.name, err)
 			}
 
 			// for testing we want to redirect calls to our server
diff --git a/providers/catalog_test.go b/providers/catalog_test.go
index 5be852be..70f5c0d4 100644
--- a/providers/catalog_test.go
+++ b/providers/catalog_test.go
@@ -145,7 +145,7 @@ var testCases = []struct { // nolint
 					Delete: false,
 				},
 				Subscribe: false,
-				Proxy:     false,
+				Proxy:     true,
 			},
 			AuthType: Oauth2,
 			OauthOpts: OauthOpts{
@@ -175,7 +175,7 @@ var testCases = []struct { // nolint
 					Delete: false,
 				},
 				Subscribe: false,
-				Proxy:     false,
+				Proxy:     true,
 			},
 			AuthType: Oauth2,
 			OauthOpts: OauthOpts{
@@ -591,10 +591,15 @@ var testCases = []struct { // nolint
 		expected: &ProviderInfo{
 			AuthType: Oauth2,
 			OauthOpts: OauthOpts{
+				GrantType:                 AuthorizationCode,
 				AuthURL:                   "https://www.dropbox.com/oauth2/authorize",
 				TokenURL:                  "https://api.dropboxapi.com/oauth2/token",
 				ExplicitScopesRequired:    false,
 				ExplicitWorkspaceRequired: false,
+				TokenMetadataFields: TokenMetadataFields{
+					ScopesField:      "scope",
+					ConsumerRefField: "account_id",
+				},
 			},
 			Support: Support{
 				BulkWrite: BulkWriteSupport{
@@ -603,7 +608,7 @@ var testCases = []struct { // nolint
 					Upsert: false,
 					Delete: false,
 				},
-				Proxy:     false,
+				Proxy:     true,
 				Read:      false,
 				Subscribe: false,
 				Write:     false,
@@ -666,8 +671,7 @@ var testCases = []struct { // nolint
 				ExplicitWorkspaceRequired: false,
 				ExplicitScopesRequired:    true,
 				TokenMetadataFields: TokenMetadataFields{
-					ScopesField:      "scope",
-					ConsumerRefField: "client_id",
+					ScopesField: "scope",
 				},
 			},
 			BaseURL: "https://api.gong.io",
@@ -728,38 +732,11 @@ var testCases = []struct { // nolint
 		},
 		expectedErr: nil,
 	},
-	// TODO: uncomment this when the docusign connector is uncommented
-	//{
-	//	provider: Docusign,
-	//	substitutions: map[string]string{
-	//		"server": "example",
-	//	},
-	//	expected: &ProviderInfo{
-	//		Support: Support{
-	//			Read:  false,
-	//			Write: false,
-	//			BulkWrite: BulkWriteSupport{
-	//				Insert: false,
-	//				Update: false,
-	//				Upsert: false,
-	//				Delete: false,
-	//			},
-	//			Subscribe: false,
-	//			Proxy:     false,
-	//		},
-	//		AuthType: Oauth2,
-	//		OauthOpts: OauthOpts{
-	//			AuthURL:                   "https://account.docusign.com/oauth/auth",
-	//			TokenURL:                  "https://account.docusign.com/oauth/token",
-	//			ExplicitScopesRequired:    true,
-	//			ExplicitWorkspaceRequired: false,
-	//		},
-	//		BaseURL: "https://example.docusign.net",
-	//	},
-	//	expectedErr: nil,
-	// },
 	{
-		provider: DocusignDeveloper,
+		provider: Docusign,
+		substitutions: map[string]string{
+			"server": "example",
+		},
 		expected: &ProviderInfo{
 			Support: Support{
 				Read:  false,
@@ -775,10 +752,40 @@ var testCases = []struct { // nolint
 			},
 			AuthType: Oauth2,
 			OauthOpts: OauthOpts{
+				AuthURL:                   "https://account.docusign.com/oauth/auth",
+				TokenURL:                  "https://account.docusign.com/oauth/token",
+				ExplicitScopesRequired:    true,
+				ExplicitWorkspaceRequired: false,
+			},
+			BaseURL: "https://example.docusign.net",
+		},
+		expectedErr: nil,
+	},
+	{
+		provider: DocusignDeveloper,
+		expected: &ProviderInfo{
+			Support: Support{
+				Read:  false,
+				Write: false,
+				BulkWrite: BulkWriteSupport{
+					Insert: false,
+					Update: false,
+					Upsert: false,
+					Delete: false,
+				},
+				Subscribe: false,
+				Proxy:     true,
+			},
+			AuthType: Oauth2,
+			OauthOpts: OauthOpts{
+				GrantType:                 AuthorizationCode,
 				AuthURL:                   "https://account-d.docusign.com/oauth/auth",
 				TokenURL:                  "https://account-d.docusign.com/oauth/token",
 				ExplicitScopesRequired:    true,
 				ExplicitWorkspaceRequired: false,
+				TokenMetadataFields: TokenMetadataFields{
+					ScopesField: "scope",
+				},
 			},
 			BaseURL: "https://demo.docusign.net",
 		},
@@ -798,7 +805,7 @@ var testCases = []struct { // nolint
 					Delete: false,
 				},
 				Subscribe: false,
-				Proxy:     false,
+				Proxy:     true,
 			},
 			AuthType: Oauth2,
 			OauthOpts: OauthOpts{
@@ -891,7 +898,7 @@ var testCases = []struct { // nolint
 				AuthURL:                   "https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
 				TokenURL:                  "https://login.microsoftonline.com/common/oauth2/v2.0/token",
 				ExplicitScopesRequired:    true,
-				ExplicitWorkspaceRequired: true,
+				ExplicitWorkspaceRequired: false,
 			},
 			BaseURL: "https://testing.api.crm.dynamics.com",
 		},
@@ -1001,10 +1008,11 @@ var testCases = []struct { // nolint
 					Delete: false,
 				},
 				Subscribe: false,
-				Proxy:     false,
+				Proxy:     true,
 			},
 			AuthType: Oauth2,
 			OauthOpts: OauthOpts{
+				GrantType:                 AuthorizationCode,
 				AuthURL:                   "https://account.box.com/api/oauth2/authorize",
 				TokenURL:                  "https://api.box.com/oauth2/token",
 				ExplicitScopesRequired:    false,
@@ -1103,6 +1111,68 @@ var testCases = []struct { // nolint
 		},
 		expectedErr: nil,
 	},
+	{
+		provider:    IroncladDemo,
+		description: "IroncladDemo config with no substitutions",
+		expected: &ProviderInfo{
+			Support: Support{
+				BulkWrite: BulkWriteSupport{
+					Insert: false,
+					Update: false,
+					Upsert: false,
+					Delete: false,
+				},
+				Read:      false,
+				Write:     false,
+				Subscribe: false,
+				Proxy:     false,
+			},
+			AuthType: Oauth2,
+			OauthOpts: OauthOpts{
+				AuthURL:                   "https://demo.ironcladapp.com/oauth/authorize",
+				TokenURL:                  "https://demo.ironcladapp.com/oauth/token",
+				ExplicitScopesRequired:    true,
+				ExplicitWorkspaceRequired: false,
+				GrantType:                 AuthorizationCode,
+				TokenMetadataFields: TokenMetadataFields{
+					ScopesField: "scope",
+				},
+			},
+			BaseURL: "https://demo.ironcladapp.com",
+		},
+		expectedErr: nil,
+	},
+	{
+		provider:    IroncladEU,
+		description: "IroncladEU config with no substitutions",
+		expected: &ProviderInfo{
+			Support: Support{
+				BulkWrite: BulkWriteSupport{
+					Insert: false,
+					Update: false,
+					Upsert: false,
+					Delete: false,
+				},
+				Read:      false,
+				Write:     false,
+				Subscribe: false,
+				Proxy:     false,
+			},
+			AuthType: Oauth2,
+			OauthOpts: OauthOpts{
+				AuthURL:                   "https://eu1.ironcladapp.com/oauth/authorize",
+				TokenURL:                  "https://eu1.ironcladapp.com/oauth/token",
+				ExplicitScopesRequired:    true,
+				ExplicitWorkspaceRequired: false,
+				GrantType:                 AuthorizationCode,
+				TokenMetadataFields: TokenMetadataFields{
+					ScopesField: "scope",
+				},
+			},
+			BaseURL: "https://eu1.ironcladapp.com",
+		},
+		expectedErr: nil,
+	},
 	{
 		provider:    Airtable,
 		description: "Valid Airtable provider config with no substitutions",
@@ -1134,6 +1204,37 @@ var testCases = []struct { // nolint
 		},
 		expectedErr: nil,
 	},
+	{
+		provider:    Ironclad,
+		description: "Ironclad config with no substitutions",
+		expected: &ProviderInfo{
+			Support: Support{
+				Read:  false,
+				Write: false,
+				BulkWrite: BulkWriteSupport{
+					Insert: false,
+					Update: false,
+					Upsert: false,
+					Delete: false,
+				},
+				Subscribe: false,
+				Proxy:     false,
+			},
+			AuthType: Oauth2,
+			OauthOpts: OauthOpts{
+				AuthURL:                   "https://ironcladapp.com/oauth/authorize",
+				TokenURL:                  "https://ironcladapp.com/oauth/token",
+				ExplicitScopesRequired:    true,
+				ExplicitWorkspaceRequired: false,
+				GrantType:                 AuthorizationCode,
+				TokenMetadataFields: TokenMetadataFields{
+					ScopesField: "scope",
+				},
+			},
+			BaseURL: "https://ironcladapp.com",
+		},
+		expectedErr: nil,
+	},
 	{
 		provider:    Slack,
 		description: "Valid Slack provider config with non-existent substitutions",
@@ -1148,7 +1249,7 @@ var testCases = []struct { // nolint
 					Delete: false,
 				},
 				Subscribe: false,
-				Proxy:     false,
+				Proxy:     true,
 			},
 			AuthType: Oauth2,
 			OauthOpts: OauthOpts{
@@ -1156,7 +1257,7 @@ var testCases = []struct { // nolint
 				AuthURL:                   "https://slack.com/oauth/v2/authorize",
 				TokenURL:                  "https://slack.com/api/oauth.v2.access",
 				ExplicitScopesRequired:    true,
-				ExplicitWorkspaceRequired: true,
+				ExplicitWorkspaceRequired: false,
 				TokenMetadataFields: TokenMetadataFields{
 					ScopesField:       "scope",
 					WorkspaceRefField: "workspace_name",
@@ -1409,7 +1510,7 @@ var testCases = []struct { // nolint
 		expectedErr: nil,
 	},
 	{
-		provider:    GoogleMail,
+		provider:    Gmail,
 		description: "Valid GoogleMail provider config with no substitutions",
 		expected: &ProviderInfo{
 			AuthType: Oauth2,
@@ -1567,7 +1668,7 @@ var testCases = []struct { // nolint
 		provider:    Zuora,
 		description: "Valid Zuora provider config with substitutions",
 		substitutions: map[string]string{
-			"subdomain": "rest.test",
+			"workspace": "rest.test",
 		},
 		expected: &ProviderInfo{
 			AuthType: Oauth2,
@@ -1576,7 +1677,7 @@ var testCases = []struct { // nolint
 				AuthURL:                   "https://rest.test.zuora.com/oauth/auth_mock",
 				TokenURL:                  "https://rest.test.zuora.com/oauth/token",
 				ExplicitScopesRequired:    false,
-				ExplicitWorkspaceRequired: false,
+				ExplicitWorkspaceRequired: true,
 			},
 			Support: Support{
 				BulkWrite: BulkWriteSupport{
@@ -1623,6 +1724,96 @@ var testCases = []struct { // nolint
 		},
 		expectedErr: nil,
 	},
+	{
+		provider:    ClickUp,
+		description: "Valid ClickUp provider config with no substitutions",
+		expected: &ProviderInfo{
+			AuthType: Oauth2,
+			OauthOpts: OauthOpts{
+				GrantType:                 AuthorizationCode,
+				AuthURL:                   "https://app.clickup.com/api",
+				TokenURL:                  "https://api.clickup.com/api/v2/oauth/token",
+				ExplicitScopesRequired:    false,
+				ExplicitWorkspaceRequired: false,
+			},
+			Support: Support{
+				BulkWrite: BulkWriteSupport{
+					Insert: false,
+					Update: false,
+					Upsert: false,
+					Delete: false,
+				},
+				Proxy:     false,
+				Read:      false,
+				Subscribe: false,
+				Write:     false,
+			},
+			BaseURL: "https://api.clickup.com",
+		},
+		expectedErr: nil,
+	},
+	{
+		provider:    Discord,
+		description: "Valid Discord provider config with no substitutions",
+		expected: &ProviderInfo{
+			AuthType: Oauth2,
+			OauthOpts: OauthOpts{
+				GrantType:                 AuthorizationCode,
+				AuthURL:                   "https://discord.com/oauth2/authorize",
+				TokenURL:                  "https://discord.com/api/oauth2/token",
+				ExplicitScopesRequired:    true,
+				ExplicitWorkspaceRequired: false,
+				TokenMetadataFields: TokenMetadataFields{
+					ScopesField: "scope",
+				},
+			},
+			Support: Support{
+				BulkWrite: BulkWriteSupport{
+					Insert: false,
+					Update: false,
+					Upsert: false,
+					Delete: false,
+				},
+				Proxy:     false,
+				Read:      false,
+				Subscribe: false,
+				Write:     false,
+			},
+			BaseURL: "https://discord.com",
+		},
+		expectedErr: nil,
+	},
+	{
+		provider:    Aircall,
+		description: "Valid Aircall provider config without substitutions",
+		expected: &ProviderInfo{
+			AuthType: Oauth2,
+			OauthOpts: OauthOpts{
+				GrantType:                 AuthorizationCode,
+				AuthURL:                   "https://dashboard.aircall.io/oauth/authorize",
+				TokenURL:                  "https://api.aircall.io/v1/oauth/token",
+				ExplicitScopesRequired:    true,
+				ExplicitWorkspaceRequired: false,
+				TokenMetadataFields: TokenMetadataFields{
+					ScopesField: "scope",
+				},
+			},
+			Support: Support{
+				BulkWrite: BulkWriteSupport{
+					Insert: false,
+					Update: false,
+					Upsert: false,
+					Delete: false,
+				},
+				Proxy:     false,
+				Read:      false,
+				Subscribe: false,
+				Write:     false,
+			},
+			BaseURL: "https://api.aircall.io",
+		},
+		expectedErr: nil,
+	},
 }
 
 func TestReadInfo(t *testing.T) { // nolint
diff --git a/salesloft/delete_test.go b/salesloft/delete_test.go
new file mode 100644
index 00000000..4aa3fe09
--- /dev/null
+++ b/salesloft/delete_test.go
@@ -0,0 +1,121 @@
+package salesloft
+
+import (
+	"context"
+	"errors"
+	"net/http"
+	"net/http/httptest"
+	"reflect"
+	"strings"
+	"testing"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/interpreter"
+	"github.com/amp-labs/connectors/test/utils/mockutils"
+	"github.com/go-test/deep"
+)
+
+func TestDelete(t *testing.T) { // nolint:funlen,cyclop
+	t.Parallel()
+
+	listSchema := mockutils.DataFromFile(t, "write-signals-error.json")
+
+	tests := []struct {
+		name         string
+		input        common.DeleteParams
+		server       *httptest.Server
+		connector    Connector
+		expected     *common.DeleteResult
+		expectedErrs []error
+	}{
+		{
+			name: "Write object must be included",
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.WriteHeader(http.StatusTeapot)
+			})),
+			expectedErrs: []error{common.ErrMissingObjects},
+		},
+		{
+			name:  "Write object and its ID must be included",
+			input: common.DeleteParams{ObjectName: "signals"},
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.WriteHeader(http.StatusTeapot)
+			})),
+			expectedErrs: []error{common.ErrMissingRecordID},
+		},
+		{
+			name:  "Mime response header expected",
+			input: common.DeleteParams{ObjectName: "signals", RecordId: "22165"},
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.WriteHeader(http.StatusTeapot)
+			})),
+			expectedErrs: []error{interpreter.ErrMissingContentType},
+		},
+		{
+			name:  "Correct error message is understood from JSON response",
+			input: common.DeleteParams{ObjectName: "signals", RecordId: "22165"},
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				w.WriteHeader(http.StatusUnprocessableEntity)
+				_, _ = w.Write(listSchema)
+			})),
+			expectedErrs: []error{
+				common.ErrBadRequest,
+				errors.New("no Signal Registration found for integration id 5167 and given type"), // nolint:goerr113
+			},
+		},
+		{
+			name:  "Successful delete",
+			input: common.DeleteParams{ObjectName: "signals", RecordId: "22165"},
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				mockutils.RespondNoContentForMethod(w, r, "DELETE")
+			})),
+			expected:     &common.DeleteResult{Success: true},
+			expectedErrs: nil,
+		},
+	}
+
+	for _, tt := range tests { // nolint:dupl
+		// nolint:varnamelen
+		tt := tt // rebind, omit loop side effects for parallel goroutine
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
+			defer tt.server.Close()
+
+			ctx := context.Background()
+
+			connector, err := NewConnector(
+				WithAuthenticatedClient(http.DefaultClient),
+			)
+			if err != nil {
+				t.Fatalf("%s: error in test while constructing connector %v", tt.name, err)
+			}
+
+			// for testing we want to redirect calls to our server
+			connector.setBaseURL(tt.server.URL)
+
+			// start of tests
+			output, err := connector.Delete(ctx, tt.input)
+			if len(tt.expectedErrs) == 0 && err != nil {
+				t.Fatalf("%s: expected no errors, got: (%v)", tt.name, err)
+			}
+
+			if len(tt.expectedErrs) != 0 && err == nil {
+				t.Fatalf("%s: expected errors (%v), but got nothing", tt.name, tt.expectedErrs)
+			}
+
+			for _, expectedErr := range tt.expectedErrs {
+				if !errors.Is(err, expectedErr) && !strings.Contains(err.Error(), expectedErr.Error()) {
+					t.Fatalf("%s: expected Error: (%v), got: (%v)", tt.name, expectedErr, err)
+				}
+			}
+
+			if !reflect.DeepEqual(output, tt.expected) {
+				diff := deep.Equal(output, tt.expected)
+				t.Fatalf("%s:, \nexpected: (%v), \ngot: (%v), \ndiff: (%v)", tt.name, tt.expected, output, diff)
+			}
+		})
+	}
+}
diff --git a/salesloft/errors_test.go b/salesloft/errors_test.go
new file mode 100644
index 00000000..3b726e5f
--- /dev/null
+++ b/salesloft/errors_test.go
@@ -0,0 +1,124 @@
+package salesloft
+
+import (
+	"errors"
+	"net/http"
+	"strings"
+	"testing"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/interpreter"
+)
+
+func TestInterpretJSONError(t *testing.T) { //nolint:funlen
+	t.Parallel()
+
+	type input struct {
+		res  *http.Response
+		body []byte
+	}
+
+	tests := []struct {
+		name        string
+		input       input
+		comparator  func(actual, expected error) bool
+		expectedErr error
+	}{
+		{
+			name: "Missing response body cannot be unmarshalled",
+			input: input{
+				res:  nil,
+				body: nil,
+			},
+			expectedErr: interpreter.ErrUnmarshal,
+		},
+		{
+			name: "Empty response body cannot be unmarshalled",
+			input: input{
+				res:  nil,
+				body: []byte(``),
+			},
+			expectedErr: interpreter.ErrUnmarshal,
+		},
+		{
+			name: "Unknown response status produces caller error",
+			input: input{
+				res: &http.Response{
+					StatusCode: http.StatusTeapot,
+				},
+				body: []byte(`{}`),
+			},
+			expectedErr: common.ErrCaller,
+		},
+		{
+			name: "Correct status of TooManyRequests",
+			input: input{
+				res: &http.Response{
+					StatusCode: http.StatusTooManyRequests,
+				},
+				body: []byte(`{"data":{"base":"BTC","currency":"USD","amount":4225.87}}`),
+			},
+			expectedErr: common.ErrLimitExceeded,
+		},
+		{
+			name: "Server error response invalid 'status' data type",
+			input: input{
+				res: &http.Response{
+					StatusCode: http.StatusBadRequest,
+				},
+				body: []byte(`{"status":"string while it should be a number"}`),
+			},
+			expectedErr: interpreter.ErrUnmarshal,
+		},
+		{
+			name: "Correct interpretation of singular error payload",
+			input: input{
+				res: &http.Response{
+					StatusCode: http.StatusBadRequest,
+				},
+				body: []byte(`{"status": 123,"error": "error message from server"}`),
+			},
+			comparator:  softStringErrComparison,
+			expectedErr: errors.New("error message from server"), // nolint:goerr113
+		},
+		{
+			name: "List schema is selected for error response",
+			input: input{
+				res: &http.Response{
+					StatusCode: http.StatusBadRequest,
+				},
+				body: []byte(`{"errors": {"details":"some helpful message"}}`),
+			},
+			comparator: softStringErrComparison,
+			// this error message is coming from response payload
+			expectedErr: errors.New("some helpful message"), // nolint:goerr113
+		},
+	}
+
+	connector := Connector{}
+
+	for _, tt := range tests {
+		// nolint:varnamelen
+		tt := tt // rebind, omit loop side effects for parallel goroutine
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
+			err := connector.interpretJSONError(tt.input.res, tt.input.body)
+
+			var ok bool
+			if tt.comparator == nil {
+				ok = errors.Is(err, tt.expectedErr)
+			} else {
+				ok = tt.comparator(err, tt.expectedErr)
+			}
+
+			if !ok {
+				t.Fatalf("%s: expected: (%v), got: (%v)", tt.name, tt.expectedErr, err)
+			}
+		})
+	}
+}
+
+func softStringErrComparison(actual, expected error) bool {
+	return strings.Contains(actual.Error(), expected.Error())
+}
diff --git a/salesloft/read_test.go b/salesloft/read_test.go
new file mode 100644
index 00000000..0a11ad22
--- /dev/null
+++ b/salesloft/read_test.go
@@ -0,0 +1,253 @@
+package salesloft
+
+import (
+	"context"
+	"errors"
+	"net/http"
+	"net/http/httptest"
+	"reflect"
+	"strings"
+	"testing"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/interpreter"
+	"github.com/amp-labs/connectors/common/jsonquery"
+	"github.com/amp-labs/connectors/test/utils/mockutils"
+	"github.com/go-test/deep"
+)
+
+func TestRead(t *testing.T) { //nolint:funlen,gocognit,cyclop,maintidx
+	t.Parallel()
+
+	responseEmptyRead := mockutils.DataFromFile(t, "read-empty.json")
+	responseListPeople := mockutils.DataFromFile(t, "read-list-people.json")
+	responseListUsers := mockutils.DataFromFile(t, "read-list-users.json")
+
+	tests := []struct {
+		name         string
+		input        common.ReadParams
+		server       *httptest.Server
+		connector    Connector
+		comparator   func(serverURL string, actual, expected *common.ReadResult) bool // custom comparison
+		expected     *common.ReadResult
+		expectedErrs []error
+	}{
+		{
+			name: "Mime response header expected",
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.WriteHeader(http.StatusTeapot)
+			})),
+			expectedErrs: []error{interpreter.ErrMissingContentType},
+		},
+		{
+			name: "Correct error message is understood from JSON response",
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				w.WriteHeader(http.StatusNotFound)
+				mockutils.WriteBody(w, `{
+					"error": "Not Found"
+				}`)
+			})),
+			expectedErrs: []error{
+				common.ErrBadRequest, errors.New("Not Found"), // nolint:goerr113
+			},
+		},
+		{
+			name: "Incorrect key in payload",
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				w.WriteHeader(http.StatusOK)
+				mockutils.WriteBody(w, `{
+					"garbage": {}
+				}`)
+			})),
+			expectedErrs: []error{jsonquery.ErrKeyNotFound},
+		},
+		{
+			name: "Incorrect data type in payload",
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				w.WriteHeader(http.StatusOK)
+				mockutils.WriteBody(w, `{
+					"data": {}
+				}`)
+			})),
+			expectedErrs: []error{jsonquery.ErrNotArray},
+		},
+		{
+			name: "Next page cursor may be missing in payload",
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				w.WriteHeader(http.StatusOK)
+				_, _ = w.Write(responseEmptyRead)
+			})),
+			expected: &common.ReadResult{
+				Data: []common.ReadResultRow{},
+				Done: true,
+			},
+			expectedErrs: nil,
+		},
+		{
+			name: "Next page URL is correctly inferred",
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				w.WriteHeader(http.StatusOK)
+				_, _ = w.Write(responseListPeople)
+			})),
+			comparator: func(baseURL string, actual, expected *common.ReadResult) bool {
+				expectedNextPage := strings.ReplaceAll(expected.NextPage.String(), "{{testServerURL}}", baseURL)
+				return actual.NextPage.String() == expectedNextPage // nolint:nlreturn
+			},
+			expected: &common.ReadResult{
+				NextPage: "{{testServerURL}}?page=2&per_page=100",
+			},
+			expectedErrs: nil,
+		},
+		{
+			name: "Successful read with 25 entries, checking one row",
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				w.WriteHeader(http.StatusOK)
+				_, _ = w.Write(responseListPeople)
+			})),
+			comparator: func(baseURL string, actual, expected *common.ReadResult) bool {
+				return mockutils.ReadResultComparator.SubsetRaw(actual, expected) &&
+					actual.Done == expected.Done &&
+					actual.Rows == expected.Rows
+			},
+			expected: &common.ReadResult{
+				Rows: 25,
+				// We are only interested to validate only first Read Row!
+				Data: []common.ReadResultRow{{
+					Fields: map[string]any{},
+					Raw: map[string]any{
+						"first_name":             "Lynnelle",
+						"email_address":          "losbourn29@paypal.com",
+						"full_email_address":     "\"Lynnelle new\" <losbourn29@paypal.com>",
+						"person_company_website": "http://paypal.com",
+					},
+				}},
+				Done: false,
+			},
+			expectedErrs: nil,
+		},
+		{
+			name: "Successful read with chosen fields",
+			input: common.ReadParams{
+				Fields: []string{"email_address", "person_company_website"},
+			},
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				w.WriteHeader(http.StatusOK)
+				_, _ = w.Write(responseListPeople)
+			})),
+			comparator: func(baseURL string, actual, expected *common.ReadResult) bool {
+				return mockutils.ReadResultComparator.SubsetFields(actual, expected) &&
+					mockutils.ReadResultComparator.SubsetRaw(actual, expected)
+			},
+			expected: &common.ReadResult{
+				Data: []common.ReadResultRow{{
+					Fields: map[string]any{
+						"email_address":          "losbourn29@paypal.com",
+						"person_company_website": "http://paypal.com",
+					},
+					Raw: map[string]any{
+						"first_name":             "Lynnelle",
+						"email_address":          "losbourn29@paypal.com",
+						"full_email_address":     "\"Lynnelle new\" <losbourn29@paypal.com>",
+						"person_company_website": "http://paypal.com",
+					},
+				}},
+			},
+			expectedErrs: nil,
+		},
+		{
+			name: "Listing Users without pagination payload",
+			input: common.ReadParams{
+				Fields: []string{"email", "guid"},
+			},
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				w.WriteHeader(http.StatusOK)
+				_, _ = w.Write(responseListUsers)
+			})),
+			comparator: func(baseURL string, actual, expected *common.ReadResult) bool {
+				return mockutils.ReadResultComparator.SubsetFields(actual, expected) &&
+					mockutils.ReadResultComparator.SubsetRaw(actual, expected)
+			},
+			expected: &common.ReadResult{
+				Rows: 1,
+				Data: []common.ReadResultRow{{
+					Fields: map[string]any{
+						"guid":  "0863ed13-7120-479b-8650-206a3679e2fb",
+						"email": "somebody@withampersand.com",
+					},
+					Raw: map[string]any{
+						"name":       "Int User",
+						"first_name": "Int",
+						"last_name":  "User",
+					},
+				}},
+				NextPage: "",
+				Done:     false,
+			},
+			expectedErrs: nil,
+		},
+	}
+
+	for _, tt := range tests {
+		// nolint:varnamelen
+		tt := tt // rebind, omit loop side effects for parallel goroutine
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
+			defer tt.server.Close()
+
+			ctx := context.Background()
+
+			connector, err := NewConnector(
+				WithAuthenticatedClient(http.DefaultClient),
+			)
+			if err != nil {
+				t.Fatalf("%s: error in test while constructing connector %v", tt.name, err)
+			}
+
+			// for testing we want to redirect calls to our mock server
+			connector.setBaseURL(tt.server.URL)
+
+			// start of tests
+			output, err := connector.Read(ctx, tt.input)
+			if err != nil {
+				if len(tt.expectedErrs) == 0 {
+					t.Fatalf("%s: expected no errors, got: (%v)", tt.name, err)
+				}
+			} else {
+				// check that missing error is what is expected
+				if len(tt.expectedErrs) != 0 {
+					t.Fatalf("%s: expected errors (%v), but got nothing", tt.name, tt.expectedErrs)
+				}
+			}
+
+			// check every error
+			for _, expectedErr := range tt.expectedErrs {
+				if !errors.Is(err, expectedErr) && !strings.Contains(err.Error(), expectedErr.Error()) {
+					t.Fatalf("%s: expected Error: (%v), got: (%v)", tt.name, expectedErr, err)
+				}
+			}
+
+			// compare desired output
+			var ok bool
+			if tt.comparator == nil {
+				// default comparison is concerned about all fields
+				ok = reflect.DeepEqual(output, tt.expected)
+			} else {
+				ok = tt.comparator(tt.server.URL, output, tt.expected)
+			}
+
+			if !ok {
+				diff := deep.Equal(output, tt.expected)
+				t.Fatalf("%s:, \nexpected: (%v), \ngot: (%v), \ndiff: (%v)", tt.name, tt.expected, output, diff)
+			}
+		})
+	}
+}
diff --git a/salesloft/test/read-empty.json b/salesloft/test/read-empty.json
new file mode 100644
index 00000000..2140ae93
--- /dev/null
+++ b/salesloft/test/read-empty.json
@@ -0,0 +1,16 @@
+{
+  "metadata": {
+    "filtering": {},
+    "paging": {
+      "per_page": 25,
+      "current_page": 1,
+      "next_page": null,
+      "prev_page": null
+    },
+    "sorting": {
+      "sort_by": "updated_at",
+      "sort_direction": "DESC NULLS LAST"
+    }
+  },
+  "data": []
+}
diff --git a/salesloft/test/read-list-people.json b/salesloft/test/read-list-people.json
new file mode 100644
index 00000000..aa9dea05
--- /dev/null
+++ b/salesloft/test/read-list-people.json
@@ -0,0 +1,1742 @@
+{
+  "metadata": {
+    "filtering": {},
+    "paging": {
+      "per_page": 25,
+      "current_page": 1,
+      "next_page": 2,
+      "prev_page": null
+    },
+    "sorting": {
+      "sort_by": "updated_at",
+      "sort_direction": "DESC NULLS LAST"
+    }
+  },
+  "data": [
+    {
+      "id": 164510523,
+      "created_at": "2024-03-07T02:46:00.302715-05:00",
+      "updated_at": "2024-03-07T04:26:14.444910-05:00",
+      "last_contacted_at": null,
+      "last_replied_at": null,
+      "first_name": "Lynnelle",
+      "last_name": "new",
+      "display_name": "Lynnelle new",
+      "email_address": "losbourn29@paypal.com",
+      "full_email_address": "\"Lynnelle new\" <losbourn29@paypal.com>",
+      "secondary_email_address": null,
+      "personal_email_address": null,
+      "phone": null,
+      "phone_extension": null,
+      "home_phone": null,
+      "mobile_phone": null,
+      "linkedin_url": null,
+      "title": null,
+      "city": null,
+      "state": null,
+      "country": null,
+      "work_city": null,
+      "work_state": null,
+      "work_country": null,
+      "crm_url": null,
+      "crm_id": null,
+      "account_crm_id": null,
+      "crm_object_type": null,
+      "owner_crm_id": null,
+      "person_company_name": null,
+      "person_company_website": "http://paypal.com",
+      "person_company_industry": null,
+      "do_not_contact": false,
+      "bouncing": false,
+      "locale": null,
+      "locale_utc_offset": null,
+      "eu_resident": false,
+      "personal_website": null,
+      "twitter_handle": null,
+      "last_contacted_type": null,
+      "job_seniority": null,
+      "custom_fields": {},
+      "tags": [],
+      "contact_restrictions": [],
+      "success_count": 0,
+      "starred": false,
+      "untouched": true,
+      "counts": {
+        "emails_sent": 0,
+        "emails_viewed": 0,
+        "emails_clicked": 0,
+        "emails_replied_to": 0,
+        "emails_bounced": 0,
+        "calls": 0
+      },
+      "account": null,
+      "owner": {
+        "_href": "https://api.salesloft.com/v2/users/51550",
+        "id": 51550
+      },
+      "last_contacted_by": null,
+      "import": null,
+      "person_stage": null,
+      "most_recent_cadence": null,
+      "last_completed_step_cadence": null,
+      "last_completed_step": null,
+      "cadences": []
+    },
+    {
+      "id": 164510464,
+      "created_at": "2024-03-07T02:45:52.460011-05:00",
+      "updated_at": "2024-03-07T02:45:52.460011-05:00",
+      "last_contacted_at": null,
+      "last_replied_at": null,
+      "first_name": "Lynnelle",
+      "last_name": "Osbourn",
+      "display_name": "Lynnelle Osbourn",
+      "email_address": "losbourn27@paypal.com",
+      "full_email_address": "\"Lynnelle Osbourn\" <losbourn27@paypal.com>",
+      "secondary_email_address": null,
+      "personal_email_address": null,
+      "phone": null,
+      "phone_extension": null,
+      "home_phone": null,
+      "mobile_phone": null,
+      "linkedin_url": null,
+      "title": null,
+      "city": null,
+      "state": null,
+      "country": null,
+      "work_city": null,
+      "work_state": null,
+      "work_country": null,
+      "crm_url": null,
+      "crm_id": null,
+      "account_crm_id": null,
+      "crm_object_type": null,
+      "owner_crm_id": null,
+      "person_company_name": null,
+      "person_company_website": "http://paypal.com",
+      "person_company_industry": null,
+      "do_not_contact": false,
+      "bouncing": false,
+      "locale": null,
+      "locale_utc_offset": null,
+      "eu_resident": false,
+      "personal_website": null,
+      "twitter_handle": null,
+      "last_contacted_type": null,
+      "job_seniority": null,
+      "custom_fields": {},
+      "tags": [],
+      "contact_restrictions": [],
+      "success_count": 0,
+      "starred": false,
+      "untouched": true,
+      "counts": {
+        "emails_sent": 0,
+        "emails_viewed": 0,
+        "emails_clicked": 0,
+        "emails_replied_to": 0,
+        "emails_bounced": 0,
+        "calls": 0
+      },
+      "account": null,
+      "owner": {
+        "_href": "https://api.salesloft.com/v2/users/51550",
+        "id": 51550
+      },
+      "last_contacted_by": null,
+      "import": null,
+      "person_stage": null,
+      "most_recent_cadence": null,
+      "last_completed_step_cadence": null,
+      "last_completed_step": null,
+      "cadences": []
+    },
+    {
+      "id": 164510404,
+      "created_at": "2024-03-07T02:45:47.219925-05:00",
+      "updated_at": "2024-03-07T02:45:47.219925-05:00",
+      "last_contacted_at": null,
+      "last_replied_at": null,
+      "first_name": "Lynnelle",
+      "last_name": "Osbourn",
+      "display_name": "Lynnelle Osbourn",
+      "email_address": "losbourn26@paypal.com",
+      "full_email_address": "\"Lynnelle Osbourn\" <losbourn26@paypal.com>",
+      "secondary_email_address": null,
+      "personal_email_address": null,
+      "phone": null,
+      "phone_extension": null,
+      "home_phone": null,
+      "mobile_phone": null,
+      "linkedin_url": null,
+      "title": null,
+      "city": null,
+      "state": null,
+      "country": null,
+      "work_city": null,
+      "work_state": null,
+      "work_country": null,
+      "crm_url": null,
+      "crm_id": null,
+      "account_crm_id": null,
+      "crm_object_type": null,
+      "owner_crm_id": null,
+      "person_company_name": null,
+      "person_company_website": "http://paypal.com",
+      "person_company_industry": null,
+      "do_not_contact": false,
+      "bouncing": false,
+      "locale": null,
+      "locale_utc_offset": null,
+      "eu_resident": false,
+      "personal_website": null,
+      "twitter_handle": null,
+      "last_contacted_type": null,
+      "job_seniority": null,
+      "custom_fields": {},
+      "tags": [],
+      "contact_restrictions": [],
+      "success_count": 0,
+      "starred": false,
+      "untouched": true,
+      "counts": {
+        "emails_sent": 0,
+        "emails_viewed": 0,
+        "emails_clicked": 0,
+        "emails_replied_to": 0,
+        "emails_bounced": 0,
+        "calls": 0
+      },
+      "account": null,
+      "owner": {
+        "_href": "https://api.salesloft.com/v2/users/51550",
+        "id": 51550
+      },
+      "last_contacted_by": null,
+      "import": null,
+      "person_stage": null,
+      "most_recent_cadence": null,
+      "last_completed_step_cadence": null,
+      "last_completed_step": null,
+      "cadences": []
+    },
+    {
+      "id": 164510380,
+      "created_at": "2024-03-07T02:45:42.980048-05:00",
+      "updated_at": "2024-03-07T02:45:42.980048-05:00",
+      "last_contacted_at": null,
+      "last_replied_at": null,
+      "first_name": "Lynnelle",
+      "last_name": "Osbourn",
+      "display_name": "Lynnelle Osbourn",
+      "email_address": "losbourn25@paypal.com",
+      "full_email_address": "\"Lynnelle Osbourn\" <losbourn25@paypal.com>",
+      "secondary_email_address": null,
+      "personal_email_address": null,
+      "phone": null,
+      "phone_extension": null,
+      "home_phone": null,
+      "mobile_phone": null,
+      "linkedin_url": null,
+      "title": null,
+      "city": null,
+      "state": null,
+      "country": null,
+      "work_city": null,
+      "work_state": null,
+      "work_country": null,
+      "crm_url": null,
+      "crm_id": null,
+      "account_crm_id": null,
+      "crm_object_type": null,
+      "owner_crm_id": null,
+      "person_company_name": null,
+      "person_company_website": "http://paypal.com",
+      "person_company_industry": null,
+      "do_not_contact": false,
+      "bouncing": false,
+      "locale": null,
+      "locale_utc_offset": null,
+      "eu_resident": false,
+      "personal_website": null,
+      "twitter_handle": null,
+      "last_contacted_type": null,
+      "job_seniority": null,
+      "custom_fields": {},
+      "tags": [],
+      "contact_restrictions": [],
+      "success_count": 0,
+      "starred": false,
+      "untouched": true,
+      "counts": {
+        "emails_sent": 0,
+        "emails_viewed": 0,
+        "emails_clicked": 0,
+        "emails_replied_to": 0,
+        "emails_bounced": 0,
+        "calls": 0
+      },
+      "account": null,
+      "owner": {
+        "_href": "https://api.salesloft.com/v2/users/51550",
+        "id": 51550
+      },
+      "last_contacted_by": null,
+      "import": null,
+      "person_stage": null,
+      "most_recent_cadence": null,
+      "last_completed_step_cadence": null,
+      "last_completed_step": null,
+      "cadences": []
+    },
+    {
+      "id": 164510343,
+      "created_at": "2024-03-07T02:45:38.900216-05:00",
+      "updated_at": "2024-03-07T02:45:38.900216-05:00",
+      "last_contacted_at": null,
+      "last_replied_at": null,
+      "first_name": "Lynnelle",
+      "last_name": "Osbourn",
+      "display_name": "Lynnelle Osbourn",
+      "email_address": "losbourn24@paypal.com",
+      "full_email_address": "\"Lynnelle Osbourn\" <losbourn24@paypal.com>",
+      "secondary_email_address": null,
+      "personal_email_address": null,
+      "phone": null,
+      "phone_extension": null,
+      "home_phone": null,
+      "mobile_phone": null,
+      "linkedin_url": null,
+      "title": null,
+      "city": null,
+      "state": null,
+      "country": null,
+      "work_city": null,
+      "work_state": null,
+      "work_country": null,
+      "crm_url": null,
+      "crm_id": null,
+      "account_crm_id": null,
+      "crm_object_type": null,
+      "owner_crm_id": null,
+      "person_company_name": null,
+      "person_company_website": "http://paypal.com",
+      "person_company_industry": null,
+      "do_not_contact": false,
+      "bouncing": false,
+      "locale": null,
+      "locale_utc_offset": null,
+      "eu_resident": false,
+      "personal_website": null,
+      "twitter_handle": null,
+      "last_contacted_type": null,
+      "job_seniority": null,
+      "custom_fields": {},
+      "tags": [],
+      "contact_restrictions": [],
+      "success_count": 0,
+      "starred": false,
+      "untouched": true,
+      "counts": {
+        "emails_sent": 0,
+        "emails_viewed": 0,
+        "emails_clicked": 0,
+        "emails_replied_to": 0,
+        "emails_bounced": 0,
+        "calls": 0
+      },
+      "account": null,
+      "owner": {
+        "_href": "https://api.salesloft.com/v2/users/51550",
+        "id": 51550
+      },
+      "last_contacted_by": null,
+      "import": null,
+      "person_stage": null,
+      "most_recent_cadence": null,
+      "last_completed_step_cadence": null,
+      "last_completed_step": null,
+      "cadences": []
+    },
+    {
+      "id": 164510294,
+      "created_at": "2024-03-07T02:45:34.547425-05:00",
+      "updated_at": "2024-03-07T02:45:34.547425-05:00",
+      "last_contacted_at": null,
+      "last_replied_at": null,
+      "first_name": "Lynnelle",
+      "last_name": "Osbourn",
+      "display_name": "Lynnelle Osbourn",
+      "email_address": "losbourn23@paypal.com",
+      "full_email_address": "\"Lynnelle Osbourn\" <losbourn23@paypal.com>",
+      "secondary_email_address": null,
+      "personal_email_address": null,
+      "phone": null,
+      "phone_extension": null,
+      "home_phone": null,
+      "mobile_phone": null,
+      "linkedin_url": null,
+      "title": null,
+      "city": null,
+      "state": null,
+      "country": null,
+      "work_city": null,
+      "work_state": null,
+      "work_country": null,
+      "crm_url": null,
+      "crm_id": null,
+      "account_crm_id": null,
+      "crm_object_type": null,
+      "owner_crm_id": null,
+      "person_company_name": null,
+      "person_company_website": "http://paypal.com",
+      "person_company_industry": null,
+      "do_not_contact": false,
+      "bouncing": false,
+      "locale": null,
+      "locale_utc_offset": null,
+      "eu_resident": false,
+      "personal_website": null,
+      "twitter_handle": null,
+      "last_contacted_type": null,
+      "job_seniority": null,
+      "custom_fields": {},
+      "tags": [],
+      "contact_restrictions": [],
+      "success_count": 0,
+      "starred": false,
+      "untouched": true,
+      "counts": {
+        "emails_sent": 0,
+        "emails_viewed": 0,
+        "emails_clicked": 0,
+        "emails_replied_to": 0,
+        "emails_bounced": 0,
+        "calls": 0
+      },
+      "account": null,
+      "owner": {
+        "_href": "https://api.salesloft.com/v2/users/51550",
+        "id": 51550
+      },
+      "last_contacted_by": null,
+      "import": null,
+      "person_stage": null,
+      "most_recent_cadence": null,
+      "last_completed_step_cadence": null,
+      "last_completed_step": null,
+      "cadences": []
+    },
+    {
+      "id": 164510231,
+      "created_at": "2024-03-07T02:45:30.347371-05:00",
+      "updated_at": "2024-03-07T02:45:30.347371-05:00",
+      "last_contacted_at": null,
+      "last_replied_at": null,
+      "first_name": "Lynnelle",
+      "last_name": "Osbourn",
+      "display_name": "Lynnelle Osbourn",
+      "email_address": "losbourn22@paypal.com",
+      "full_email_address": "\"Lynnelle Osbourn\" <losbourn22@paypal.com>",
+      "secondary_email_address": null,
+      "personal_email_address": null,
+      "phone": null,
+      "phone_extension": null,
+      "home_phone": null,
+      "mobile_phone": null,
+      "linkedin_url": null,
+      "title": null,
+      "city": null,
+      "state": null,
+      "country": null,
+      "work_city": null,
+      "work_state": null,
+      "work_country": null,
+      "crm_url": null,
+      "crm_id": null,
+      "account_crm_id": null,
+      "crm_object_type": null,
+      "owner_crm_id": null,
+      "person_company_name": null,
+      "person_company_website": "http://paypal.com",
+      "person_company_industry": null,
+      "do_not_contact": false,
+      "bouncing": false,
+      "locale": null,
+      "locale_utc_offset": null,
+      "eu_resident": false,
+      "personal_website": null,
+      "twitter_handle": null,
+      "last_contacted_type": null,
+      "job_seniority": null,
+      "custom_fields": {},
+      "tags": [],
+      "contact_restrictions": [],
+      "success_count": 0,
+      "starred": false,
+      "untouched": true,
+      "counts": {
+        "emails_sent": 0,
+        "emails_viewed": 0,
+        "emails_clicked": 0,
+        "emails_replied_to": 0,
+        "emails_bounced": 0,
+        "calls": 0
+      },
+      "account": null,
+      "owner": {
+        "_href": "https://api.salesloft.com/v2/users/51550",
+        "id": 51550
+      },
+      "last_contacted_by": null,
+      "import": null,
+      "person_stage": null,
+      "most_recent_cadence": null,
+      "last_completed_step_cadence": null,
+      "last_completed_step": null,
+      "cadences": []
+    },
+    {
+      "id": 164510159,
+      "created_at": "2024-03-07T02:45:25.744427-05:00",
+      "updated_at": "2024-03-07T02:45:25.744427-05:00",
+      "last_contacted_at": null,
+      "last_replied_at": null,
+      "first_name": "Lynnelle",
+      "last_name": "Osbourn",
+      "display_name": "Lynnelle Osbourn",
+      "email_address": "losbourn21@paypal.com",
+      "full_email_address": "\"Lynnelle Osbourn\" <losbourn21@paypal.com>",
+      "secondary_email_address": null,
+      "personal_email_address": null,
+      "phone": null,
+      "phone_extension": null,
+      "home_phone": null,
+      "mobile_phone": null,
+      "linkedin_url": null,
+      "title": null,
+      "city": null,
+      "state": null,
+      "country": null,
+      "work_city": null,
+      "work_state": null,
+      "work_country": null,
+      "crm_url": null,
+      "crm_id": null,
+      "account_crm_id": null,
+      "crm_object_type": null,
+      "owner_crm_id": null,
+      "person_company_name": null,
+      "person_company_website": "http://paypal.com",
+      "person_company_industry": null,
+      "do_not_contact": false,
+      "bouncing": false,
+      "locale": null,
+      "locale_utc_offset": null,
+      "eu_resident": false,
+      "personal_website": null,
+      "twitter_handle": null,
+      "last_contacted_type": null,
+      "job_seniority": null,
+      "custom_fields": {},
+      "tags": [],
+      "contact_restrictions": [],
+      "success_count": 0,
+      "starred": false,
+      "untouched": true,
+      "counts": {
+        "emails_sent": 0,
+        "emails_viewed": 0,
+        "emails_clicked": 0,
+        "emails_replied_to": 0,
+        "emails_bounced": 0,
+        "calls": 0
+      },
+      "account": null,
+      "owner": {
+        "_href": "https://api.salesloft.com/v2/users/51550",
+        "id": 51550
+      },
+      "last_contacted_by": null,
+      "import": null,
+      "person_stage": null,
+      "most_recent_cadence": null,
+      "last_completed_step_cadence": null,
+      "last_completed_step": null,
+      "cadences": []
+    },
+    {
+      "id": 164509947,
+      "created_at": "2024-03-07T02:45:00.786591-05:00",
+      "updated_at": "2024-03-07T02:45:00.786591-05:00",
+      "last_contacted_at": null,
+      "last_replied_at": null,
+      "first_name": "Lynnelle",
+      "last_name": "Osbourn",
+      "display_name": "Lynnelle Osbourn",
+      "email_address": "losbourn20@paypal.com",
+      "full_email_address": "\"Lynnelle Osbourn\" <losbourn20@paypal.com>",
+      "secondary_email_address": null,
+      "personal_email_address": null,
+      "phone": null,
+      "phone_extension": null,
+      "home_phone": null,
+      "mobile_phone": null,
+      "linkedin_url": null,
+      "title": null,
+      "city": null,
+      "state": null,
+      "country": null,
+      "work_city": null,
+      "work_state": null,
+      "work_country": null,
+      "crm_url": null,
+      "crm_id": null,
+      "account_crm_id": null,
+      "crm_object_type": null,
+      "owner_crm_id": null,
+      "person_company_name": null,
+      "person_company_website": "http://paypal.com",
+      "person_company_industry": null,
+      "do_not_contact": false,
+      "bouncing": false,
+      "locale": null,
+      "locale_utc_offset": null,
+      "eu_resident": false,
+      "personal_website": null,
+      "twitter_handle": null,
+      "last_contacted_type": null,
+      "job_seniority": null,
+      "custom_fields": {},
+      "tags": [],
+      "contact_restrictions": [],
+      "success_count": 0,
+      "starred": false,
+      "untouched": true,
+      "counts": {
+        "emails_sent": 0,
+        "emails_viewed": 0,
+        "emails_clicked": 0,
+        "emails_replied_to": 0,
+        "emails_bounced": 0,
+        "calls": 0
+      },
+      "account": null,
+      "owner": {
+        "_href": "https://api.salesloft.com/v2/users/51550",
+        "id": 51550
+      },
+      "last_contacted_by": null,
+      "import": null,
+      "person_stage": null,
+      "most_recent_cadence": null,
+      "last_completed_step_cadence": null,
+      "last_completed_step": null,
+      "cadences": []
+    },
+    {
+      "id": 164509882,
+      "created_at": "2024-03-07T02:44:52.236661-05:00",
+      "updated_at": "2024-03-07T02:44:52.236661-05:00",
+      "last_contacted_at": null,
+      "last_replied_at": null,
+      "first_name": "Lynnelle",
+      "last_name": "Osbourn",
+      "display_name": "Lynnelle Osbourn",
+      "email_address": "losbourn19@paypal.com",
+      "full_email_address": "\"Lynnelle Osbourn\" <losbourn19@paypal.com>",
+      "secondary_email_address": null,
+      "personal_email_address": null,
+      "phone": null,
+      "phone_extension": null,
+      "home_phone": null,
+      "mobile_phone": null,
+      "linkedin_url": null,
+      "title": null,
+      "city": null,
+      "state": null,
+      "country": null,
+      "work_city": null,
+      "work_state": null,
+      "work_country": null,
+      "crm_url": null,
+      "crm_id": null,
+      "account_crm_id": null,
+      "crm_object_type": null,
+      "owner_crm_id": null,
+      "person_company_name": null,
+      "person_company_website": "http://paypal.com",
+      "person_company_industry": null,
+      "do_not_contact": false,
+      "bouncing": false,
+      "locale": null,
+      "locale_utc_offset": null,
+      "eu_resident": false,
+      "personal_website": null,
+      "twitter_handle": null,
+      "last_contacted_type": null,
+      "job_seniority": null,
+      "custom_fields": {},
+      "tags": [],
+      "contact_restrictions": [],
+      "success_count": 0,
+      "starred": false,
+      "untouched": true,
+      "counts": {
+        "emails_sent": 0,
+        "emails_viewed": 0,
+        "emails_clicked": 0,
+        "emails_replied_to": 0,
+        "emails_bounced": 0,
+        "calls": 0
+      },
+      "account": null,
+      "owner": {
+        "_href": "https://api.salesloft.com/v2/users/51550",
+        "id": 51550
+      },
+      "last_contacted_by": null,
+      "import": null,
+      "person_stage": null,
+      "most_recent_cadence": null,
+      "last_completed_step_cadence": null,
+      "last_completed_step": null,
+      "cadences": []
+    },
+    {
+      "id": 164509852,
+      "created_at": "2024-03-07T02:44:46.694737-05:00",
+      "updated_at": "2024-03-07T02:44:46.694737-05:00",
+      "last_contacted_at": null,
+      "last_replied_at": null,
+      "first_name": "Lynnelle",
+      "last_name": "Osbourn",
+      "display_name": "Lynnelle Osbourn",
+      "email_address": "losbourn18@paypal.com",
+      "full_email_address": "\"Lynnelle Osbourn\" <losbourn18@paypal.com>",
+      "secondary_email_address": null,
+      "personal_email_address": null,
+      "phone": null,
+      "phone_extension": null,
+      "home_phone": null,
+      "mobile_phone": null,
+      "linkedin_url": null,
+      "title": null,
+      "city": null,
+      "state": null,
+      "country": null,
+      "work_city": null,
+      "work_state": null,
+      "work_country": null,
+      "crm_url": null,
+      "crm_id": null,
+      "account_crm_id": null,
+      "crm_object_type": null,
+      "owner_crm_id": null,
+      "person_company_name": null,
+      "person_company_website": "http://paypal.com",
+      "person_company_industry": null,
+      "do_not_contact": false,
+      "bouncing": false,
+      "locale": null,
+      "locale_utc_offset": null,
+      "eu_resident": false,
+      "personal_website": null,
+      "twitter_handle": null,
+      "last_contacted_type": null,
+      "job_seniority": null,
+      "custom_fields": {},
+      "tags": [],
+      "contact_restrictions": [],
+      "success_count": 0,
+      "starred": false,
+      "untouched": true,
+      "counts": {
+        "emails_sent": 0,
+        "emails_viewed": 0,
+        "emails_clicked": 0,
+        "emails_replied_to": 0,
+        "emails_bounced": 0,
+        "calls": 0
+      },
+      "account": null,
+      "owner": {
+        "_href": "https://api.salesloft.com/v2/users/51550",
+        "id": 51550
+      },
+      "last_contacted_by": null,
+      "import": null,
+      "person_stage": null,
+      "most_recent_cadence": null,
+      "last_completed_step_cadence": null,
+      "last_completed_step": null,
+      "cadences": []
+    },
+    {
+      "id": 164509820,
+      "created_at": "2024-03-07T02:44:41.670451-05:00",
+      "updated_at": "2024-03-07T02:44:41.670451-05:00",
+      "last_contacted_at": null,
+      "last_replied_at": null,
+      "first_name": "Lynnelle",
+      "last_name": "Osbourn",
+      "display_name": "Lynnelle Osbourn",
+      "email_address": "losbourn17@paypal.com",
+      "full_email_address": "\"Lynnelle Osbourn\" <losbourn17@paypal.com>",
+      "secondary_email_address": null,
+      "personal_email_address": null,
+      "phone": null,
+      "phone_extension": null,
+      "home_phone": null,
+      "mobile_phone": null,
+      "linkedin_url": null,
+      "title": null,
+      "city": null,
+      "state": null,
+      "country": null,
+      "work_city": null,
+      "work_state": null,
+      "work_country": null,
+      "crm_url": null,
+      "crm_id": null,
+      "account_crm_id": null,
+      "crm_object_type": null,
+      "owner_crm_id": null,
+      "person_company_name": null,
+      "person_company_website": "http://paypal.com",
+      "person_company_industry": null,
+      "do_not_contact": false,
+      "bouncing": false,
+      "locale": null,
+      "locale_utc_offset": null,
+      "eu_resident": false,
+      "personal_website": null,
+      "twitter_handle": null,
+      "last_contacted_type": null,
+      "job_seniority": null,
+      "custom_fields": {},
+      "tags": [],
+      "contact_restrictions": [],
+      "success_count": 0,
+      "starred": false,
+      "untouched": true,
+      "counts": {
+        "emails_sent": 0,
+        "emails_viewed": 0,
+        "emails_clicked": 0,
+        "emails_replied_to": 0,
+        "emails_bounced": 0,
+        "calls": 0
+      },
+      "account": null,
+      "owner": {
+        "_href": "https://api.salesloft.com/v2/users/51550",
+        "id": 51550
+      },
+      "last_contacted_by": null,
+      "import": null,
+      "person_stage": null,
+      "most_recent_cadence": null,
+      "last_completed_step_cadence": null,
+      "last_completed_step": null,
+      "cadences": []
+    },
+    {
+      "id": 164509798,
+      "created_at": "2024-03-07T02:44:37.035055-05:00",
+      "updated_at": "2024-03-07T02:44:37.035055-05:00",
+      "last_contacted_at": null,
+      "last_replied_at": null,
+      "first_name": "Lynnelle",
+      "last_name": "Osbourn",
+      "display_name": "Lynnelle Osbourn",
+      "email_address": "losbourn16@paypal.com",
+      "full_email_address": "\"Lynnelle Osbourn\" <losbourn16@paypal.com>",
+      "secondary_email_address": null,
+      "personal_email_address": null,
+      "phone": null,
+      "phone_extension": null,
+      "home_phone": null,
+      "mobile_phone": null,
+      "linkedin_url": null,
+      "title": null,
+      "city": null,
+      "state": null,
+      "country": null,
+      "work_city": null,
+      "work_state": null,
+      "work_country": null,
+      "crm_url": null,
+      "crm_id": null,
+      "account_crm_id": null,
+      "crm_object_type": null,
+      "owner_crm_id": null,
+      "person_company_name": null,
+      "person_company_website": "http://paypal.com",
+      "person_company_industry": null,
+      "do_not_contact": false,
+      "bouncing": false,
+      "locale": null,
+      "locale_utc_offset": null,
+      "eu_resident": false,
+      "personal_website": null,
+      "twitter_handle": null,
+      "last_contacted_type": null,
+      "job_seniority": null,
+      "custom_fields": {},
+      "tags": [],
+      "contact_restrictions": [],
+      "success_count": 0,
+      "starred": false,
+      "untouched": true,
+      "counts": {
+        "emails_sent": 0,
+        "emails_viewed": 0,
+        "emails_clicked": 0,
+        "emails_replied_to": 0,
+        "emails_bounced": 0,
+        "calls": 0
+      },
+      "account": null,
+      "owner": {
+        "_href": "https://api.salesloft.com/v2/users/51550",
+        "id": 51550
+      },
+      "last_contacted_by": null,
+      "import": null,
+      "person_stage": null,
+      "most_recent_cadence": null,
+      "last_completed_step_cadence": null,
+      "last_completed_step": null,
+      "cadences": []
+    },
+    {
+      "id": 164509772,
+      "created_at": "2024-03-07T02:44:33.085108-05:00",
+      "updated_at": "2024-03-07T02:44:33.085108-05:00",
+      "last_contacted_at": null,
+      "last_replied_at": null,
+      "first_name": "Lynnelle",
+      "last_name": "Osbourn",
+      "display_name": "Lynnelle Osbourn",
+      "email_address": "losbourn15@paypal.com",
+      "full_email_address": "\"Lynnelle Osbourn\" <losbourn15@paypal.com>",
+      "secondary_email_address": null,
+      "personal_email_address": null,
+      "phone": null,
+      "phone_extension": null,
+      "home_phone": null,
+      "mobile_phone": null,
+      "linkedin_url": null,
+      "title": null,
+      "city": null,
+      "state": null,
+      "country": null,
+      "work_city": null,
+      "work_state": null,
+      "work_country": null,
+      "crm_url": null,
+      "crm_id": null,
+      "account_crm_id": null,
+      "crm_object_type": null,
+      "owner_crm_id": null,
+      "person_company_name": null,
+      "person_company_website": "http://paypal.com",
+      "person_company_industry": null,
+      "do_not_contact": false,
+      "bouncing": false,
+      "locale": null,
+      "locale_utc_offset": null,
+      "eu_resident": false,
+      "personal_website": null,
+      "twitter_handle": null,
+      "last_contacted_type": null,
+      "job_seniority": null,
+      "custom_fields": {},
+      "tags": [],
+      "contact_restrictions": [],
+      "success_count": 0,
+      "starred": false,
+      "untouched": true,
+      "counts": {
+        "emails_sent": 0,
+        "emails_viewed": 0,
+        "emails_clicked": 0,
+        "emails_replied_to": 0,
+        "emails_bounced": 0,
+        "calls": 0
+      },
+      "account": null,
+      "owner": {
+        "_href": "https://api.salesloft.com/v2/users/51550",
+        "id": 51550
+      },
+      "last_contacted_by": null,
+      "import": null,
+      "person_stage": null,
+      "most_recent_cadence": null,
+      "last_completed_step_cadence": null,
+      "last_completed_step": null,
+      "cadences": []
+    },
+    {
+      "id": 164509770,
+      "created_at": "2024-03-07T02:44:29.072745-05:00",
+      "updated_at": "2024-03-07T02:44:29.072745-05:00",
+      "last_contacted_at": null,
+      "last_replied_at": null,
+      "first_name": "Lynnelle",
+      "last_name": "Osbourn",
+      "display_name": "Lynnelle Osbourn",
+      "email_address": "losbourn14@paypal.com",
+      "full_email_address": "\"Lynnelle Osbourn\" <losbourn14@paypal.com>",
+      "secondary_email_address": null,
+      "personal_email_address": null,
+      "phone": null,
+      "phone_extension": null,
+      "home_phone": null,
+      "mobile_phone": null,
+      "linkedin_url": null,
+      "title": null,
+      "city": null,
+      "state": null,
+      "country": null,
+      "work_city": null,
+      "work_state": null,
+      "work_country": null,
+      "crm_url": null,
+      "crm_id": null,
+      "account_crm_id": null,
+      "crm_object_type": null,
+      "owner_crm_id": null,
+      "person_company_name": null,
+      "person_company_website": "http://paypal.com",
+      "person_company_industry": null,
+      "do_not_contact": false,
+      "bouncing": false,
+      "locale": null,
+      "locale_utc_offset": null,
+      "eu_resident": false,
+      "personal_website": null,
+      "twitter_handle": null,
+      "last_contacted_type": null,
+      "job_seniority": null,
+      "custom_fields": {},
+      "tags": [],
+      "contact_restrictions": [],
+      "success_count": 0,
+      "starred": false,
+      "untouched": true,
+      "counts": {
+        "emails_sent": 0,
+        "emails_viewed": 0,
+        "emails_clicked": 0,
+        "emails_replied_to": 0,
+        "emails_bounced": 0,
+        "calls": 0
+      },
+      "account": null,
+      "owner": {
+        "_href": "https://api.salesloft.com/v2/users/51550",
+        "id": 51550
+      },
+      "last_contacted_by": null,
+      "import": null,
+      "person_stage": null,
+      "most_recent_cadence": null,
+      "last_completed_step_cadence": null,
+      "last_completed_step": null,
+      "cadences": []
+    },
+    {
+      "id": 164509769,
+      "created_at": "2024-03-07T02:44:24.878764-05:00",
+      "updated_at": "2024-03-07T02:44:24.878764-05:00",
+      "last_contacted_at": null,
+      "last_replied_at": null,
+      "first_name": "Lynnelle",
+      "last_name": "Osbourn",
+      "display_name": "Lynnelle Osbourn",
+      "email_address": "losbourn13@paypal.com",
+      "full_email_address": "\"Lynnelle Osbourn\" <losbourn13@paypal.com>",
+      "secondary_email_address": null,
+      "personal_email_address": null,
+      "phone": null,
+      "phone_extension": null,
+      "home_phone": null,
+      "mobile_phone": null,
+      "linkedin_url": null,
+      "title": null,
+      "city": null,
+      "state": null,
+      "country": null,
+      "work_city": null,
+      "work_state": null,
+      "work_country": null,
+      "crm_url": null,
+      "crm_id": null,
+      "account_crm_id": null,
+      "crm_object_type": null,
+      "owner_crm_id": null,
+      "person_company_name": null,
+      "person_company_website": "http://paypal.com",
+      "person_company_industry": null,
+      "do_not_contact": false,
+      "bouncing": false,
+      "locale": null,
+      "locale_utc_offset": null,
+      "eu_resident": false,
+      "personal_website": null,
+      "twitter_handle": null,
+      "last_contacted_type": null,
+      "job_seniority": null,
+      "custom_fields": {},
+      "tags": [],
+      "contact_restrictions": [],
+      "success_count": 0,
+      "starred": false,
+      "untouched": true,
+      "counts": {
+        "emails_sent": 0,
+        "emails_viewed": 0,
+        "emails_clicked": 0,
+        "emails_replied_to": 0,
+        "emails_bounced": 0,
+        "calls": 0
+      },
+      "account": null,
+      "owner": {
+        "_href": "https://api.salesloft.com/v2/users/51550",
+        "id": 51550
+      },
+      "last_contacted_by": null,
+      "import": null,
+      "person_stage": null,
+      "most_recent_cadence": null,
+      "last_completed_step_cadence": null,
+      "last_completed_step": null,
+      "cadences": []
+    },
+    {
+      "id": 164509768,
+      "created_at": "2024-03-07T02:44:21.122586-05:00",
+      "updated_at": "2024-03-07T02:44:21.122586-05:00",
+      "last_contacted_at": null,
+      "last_replied_at": null,
+      "first_name": "Lynnelle",
+      "last_name": "Osbourn",
+      "display_name": "Lynnelle Osbourn",
+      "email_address": "losbourn12@paypal.com",
+      "full_email_address": "\"Lynnelle Osbourn\" <losbourn12@paypal.com>",
+      "secondary_email_address": null,
+      "personal_email_address": null,
+      "phone": null,
+      "phone_extension": null,
+      "home_phone": null,
+      "mobile_phone": null,
+      "linkedin_url": null,
+      "title": null,
+      "city": null,
+      "state": null,
+      "country": null,
+      "work_city": null,
+      "work_state": null,
+      "work_country": null,
+      "crm_url": null,
+      "crm_id": null,
+      "account_crm_id": null,
+      "crm_object_type": null,
+      "owner_crm_id": null,
+      "person_company_name": null,
+      "person_company_website": "http://paypal.com",
+      "person_company_industry": null,
+      "do_not_contact": false,
+      "bouncing": false,
+      "locale": null,
+      "locale_utc_offset": null,
+      "eu_resident": false,
+      "personal_website": null,
+      "twitter_handle": null,
+      "last_contacted_type": null,
+      "job_seniority": null,
+      "custom_fields": {},
+      "tags": [],
+      "contact_restrictions": [],
+      "success_count": 0,
+      "starred": false,
+      "untouched": true,
+      "counts": {
+        "emails_sent": 0,
+        "emails_viewed": 0,
+        "emails_clicked": 0,
+        "emails_replied_to": 0,
+        "emails_bounced": 0,
+        "calls": 0
+      },
+      "account": null,
+      "owner": {
+        "_href": "https://api.salesloft.com/v2/users/51550",
+        "id": 51550
+      },
+      "last_contacted_by": null,
+      "import": null,
+      "person_stage": null,
+      "most_recent_cadence": null,
+      "last_completed_step_cadence": null,
+      "last_completed_step": null,
+      "cadences": []
+    },
+    {
+      "id": 164509767,
+      "created_at": "2024-03-07T02:44:16.962540-05:00",
+      "updated_at": "2024-03-07T02:44:16.962540-05:00",
+      "last_contacted_at": null,
+      "last_replied_at": null,
+      "first_name": "Lynnelle",
+      "last_name": "Osbourn",
+      "display_name": "Lynnelle Osbourn",
+      "email_address": "losbourn11@paypal.com",
+      "full_email_address": "\"Lynnelle Osbourn\" <losbourn11@paypal.com>",
+      "secondary_email_address": null,
+      "personal_email_address": null,
+      "phone": null,
+      "phone_extension": null,
+      "home_phone": null,
+      "mobile_phone": null,
+      "linkedin_url": null,
+      "title": null,
+      "city": null,
+      "state": null,
+      "country": null,
+      "work_city": null,
+      "work_state": null,
+      "work_country": null,
+      "crm_url": null,
+      "crm_id": null,
+      "account_crm_id": null,
+      "crm_object_type": null,
+      "owner_crm_id": null,
+      "person_company_name": null,
+      "person_company_website": "http://paypal.com",
+      "person_company_industry": null,
+      "do_not_contact": false,
+      "bouncing": false,
+      "locale": null,
+      "locale_utc_offset": null,
+      "eu_resident": false,
+      "personal_website": null,
+      "twitter_handle": null,
+      "last_contacted_type": null,
+      "job_seniority": null,
+      "custom_fields": {},
+      "tags": [],
+      "contact_restrictions": [],
+      "success_count": 0,
+      "starred": false,
+      "untouched": true,
+      "counts": {
+        "emails_sent": 0,
+        "emails_viewed": 0,
+        "emails_clicked": 0,
+        "emails_replied_to": 0,
+        "emails_bounced": 0,
+        "calls": 0
+      },
+      "account": null,
+      "owner": {
+        "_href": "https://api.salesloft.com/v2/users/51550",
+        "id": 51550
+      },
+      "last_contacted_by": null,
+      "import": null,
+      "person_stage": null,
+      "most_recent_cadence": null,
+      "last_completed_step_cadence": null,
+      "last_completed_step": null,
+      "cadences": []
+    },
+    {
+      "id": 164509765,
+      "created_at": "2024-03-07T02:44:09.568460-05:00",
+      "updated_at": "2024-03-07T02:44:09.568460-05:00",
+      "last_contacted_at": null,
+      "last_replied_at": null,
+      "first_name": "Lynnelle",
+      "last_name": "Osbourn",
+      "display_name": "Lynnelle Osbourn",
+      "email_address": "losbourn10@paypal.com",
+      "full_email_address": "\"Lynnelle Osbourn\" <losbourn10@paypal.com>",
+      "secondary_email_address": null,
+      "personal_email_address": null,
+      "phone": null,
+      "phone_extension": null,
+      "home_phone": null,
+      "mobile_phone": null,
+      "linkedin_url": null,
+      "title": null,
+      "city": null,
+      "state": null,
+      "country": null,
+      "work_city": null,
+      "work_state": null,
+      "work_country": null,
+      "crm_url": null,
+      "crm_id": null,
+      "account_crm_id": null,
+      "crm_object_type": null,
+      "owner_crm_id": null,
+      "person_company_name": null,
+      "person_company_website": "http://paypal.com",
+      "person_company_industry": null,
+      "do_not_contact": false,
+      "bouncing": false,
+      "locale": null,
+      "locale_utc_offset": null,
+      "eu_resident": false,
+      "personal_website": null,
+      "twitter_handle": null,
+      "last_contacted_type": null,
+      "job_seniority": null,
+      "custom_fields": {},
+      "tags": [],
+      "contact_restrictions": [],
+      "success_count": 0,
+      "starred": false,
+      "untouched": true,
+      "counts": {
+        "emails_sent": 0,
+        "emails_viewed": 0,
+        "emails_clicked": 0,
+        "emails_replied_to": 0,
+        "emails_bounced": 0,
+        "calls": 0
+      },
+      "account": null,
+      "owner": {
+        "_href": "https://api.salesloft.com/v2/users/51550",
+        "id": 51550
+      },
+      "last_contacted_by": null,
+      "import": null,
+      "person_stage": null,
+      "most_recent_cadence": null,
+      "last_completed_step_cadence": null,
+      "last_completed_step": null,
+      "cadences": []
+    },
+    {
+      "id": 164509763,
+      "created_at": "2024-03-07T02:44:00.735538-05:00",
+      "updated_at": "2024-03-07T02:44:00.735538-05:00",
+      "last_contacted_at": null,
+      "last_replied_at": null,
+      "first_name": "Lynnelle",
+      "last_name": "Osbourn",
+      "display_name": "Lynnelle Osbourn",
+      "email_address": "losbourn9@paypal.com",
+      "full_email_address": "\"Lynnelle Osbourn\" <losbourn9@paypal.com>",
+      "secondary_email_address": null,
+      "personal_email_address": null,
+      "phone": null,
+      "phone_extension": null,
+      "home_phone": null,
+      "mobile_phone": null,
+      "linkedin_url": null,
+      "title": null,
+      "city": null,
+      "state": null,
+      "country": null,
+      "work_city": null,
+      "work_state": null,
+      "work_country": null,
+      "crm_url": null,
+      "crm_id": null,
+      "account_crm_id": null,
+      "crm_object_type": null,
+      "owner_crm_id": null,
+      "person_company_name": null,
+      "person_company_website": "http://paypal.com",
+      "person_company_industry": null,
+      "do_not_contact": false,
+      "bouncing": false,
+      "locale": null,
+      "locale_utc_offset": null,
+      "eu_resident": false,
+      "personal_website": null,
+      "twitter_handle": null,
+      "last_contacted_type": null,
+      "job_seniority": null,
+      "custom_fields": {},
+      "tags": [],
+      "contact_restrictions": [],
+      "success_count": 0,
+      "starred": false,
+      "untouched": true,
+      "counts": {
+        "emails_sent": 0,
+        "emails_viewed": 0,
+        "emails_clicked": 0,
+        "emails_replied_to": 0,
+        "emails_bounced": 0,
+        "calls": 0
+      },
+      "account": null,
+      "owner": {
+        "_href": "https://api.salesloft.com/v2/users/51550",
+        "id": 51550
+      },
+      "last_contacted_by": null,
+      "import": null,
+      "person_stage": null,
+      "most_recent_cadence": null,
+      "last_completed_step_cadence": null,
+      "last_completed_step": null,
+      "cadences": []
+    },
+    {
+      "id": 164509761,
+      "created_at": "2024-03-07T02:43:49.375309-05:00",
+      "updated_at": "2024-03-07T02:43:49.375309-05:00",
+      "last_contacted_at": null,
+      "last_replied_at": null,
+      "first_name": "Lynnelle",
+      "last_name": "Osbourn",
+      "display_name": "Lynnelle Osbourn",
+      "email_address": "losbourn8@paypal.com",
+      "full_email_address": "\"Lynnelle Osbourn\" <losbourn8@paypal.com>",
+      "secondary_email_address": null,
+      "personal_email_address": null,
+      "phone": null,
+      "phone_extension": null,
+      "home_phone": null,
+      "mobile_phone": null,
+      "linkedin_url": null,
+      "title": null,
+      "city": null,
+      "state": null,
+      "country": null,
+      "work_city": null,
+      "work_state": null,
+      "work_country": null,
+      "crm_url": null,
+      "crm_id": null,
+      "account_crm_id": null,
+      "crm_object_type": null,
+      "owner_crm_id": null,
+      "person_company_name": null,
+      "person_company_website": "http://paypal.com",
+      "person_company_industry": null,
+      "do_not_contact": false,
+      "bouncing": false,
+      "locale": null,
+      "locale_utc_offset": null,
+      "eu_resident": false,
+      "personal_website": null,
+      "twitter_handle": null,
+      "last_contacted_type": null,
+      "job_seniority": null,
+      "custom_fields": {},
+      "tags": [],
+      "contact_restrictions": [],
+      "success_count": 0,
+      "starred": false,
+      "untouched": true,
+      "counts": {
+        "emails_sent": 0,
+        "emails_viewed": 0,
+        "emails_clicked": 0,
+        "emails_replied_to": 0,
+        "emails_bounced": 0,
+        "calls": 0
+      },
+      "account": null,
+      "owner": {
+        "_href": "https://api.salesloft.com/v2/users/51550",
+        "id": 51550
+      },
+      "last_contacted_by": null,
+      "import": null,
+      "person_stage": null,
+      "most_recent_cadence": null,
+      "last_completed_step_cadence": null,
+      "last_completed_step": null,
+      "cadences": []
+    },
+    {
+      "id": 164509612,
+      "created_at": "2024-03-07T02:43:44.553128-05:00",
+      "updated_at": "2024-03-07T02:43:44.553128-05:00",
+      "last_contacted_at": null,
+      "last_replied_at": null,
+      "first_name": "Lynnelle",
+      "last_name": "Osbourn",
+      "display_name": "Lynnelle Osbourn",
+      "email_address": "losbourn7@paypal.com",
+      "full_email_address": "\"Lynnelle Osbourn\" <losbourn7@paypal.com>",
+      "secondary_email_address": null,
+      "personal_email_address": null,
+      "phone": null,
+      "phone_extension": null,
+      "home_phone": null,
+      "mobile_phone": null,
+      "linkedin_url": null,
+      "title": null,
+      "city": null,
+      "state": null,
+      "country": null,
+      "work_city": null,
+      "work_state": null,
+      "work_country": null,
+      "crm_url": null,
+      "crm_id": null,
+      "account_crm_id": null,
+      "crm_object_type": null,
+      "owner_crm_id": null,
+      "person_company_name": null,
+      "person_company_website": "http://paypal.com",
+      "person_company_industry": null,
+      "do_not_contact": false,
+      "bouncing": false,
+      "locale": null,
+      "locale_utc_offset": null,
+      "eu_resident": false,
+      "personal_website": null,
+      "twitter_handle": null,
+      "last_contacted_type": null,
+      "job_seniority": null,
+      "custom_fields": {},
+      "tags": [],
+      "contact_restrictions": [],
+      "success_count": 0,
+      "starred": false,
+      "untouched": true,
+      "counts": {
+        "emails_sent": 0,
+        "emails_viewed": 0,
+        "emails_clicked": 0,
+        "emails_replied_to": 0,
+        "emails_bounced": 0,
+        "calls": 0
+      },
+      "account": null,
+      "owner": {
+        "_href": "https://api.salesloft.com/v2/users/51550",
+        "id": 51550
+      },
+      "last_contacted_by": null,
+      "import": null,
+      "person_stage": null,
+      "most_recent_cadence": null,
+      "last_completed_step_cadence": null,
+      "last_completed_step": null,
+      "cadences": []
+    },
+    {
+      "id": 164509602,
+      "created_at": "2024-03-07T02:43:38.067434-05:00",
+      "updated_at": "2024-03-07T02:43:38.067434-05:00",
+      "last_contacted_at": null,
+      "last_replied_at": null,
+      "first_name": "Lynnelle",
+      "last_name": "Osbourn",
+      "display_name": "Lynnelle Osbourn",
+      "email_address": "losbourn6@paypal.com",
+      "full_email_address": "\"Lynnelle Osbourn\" <losbourn6@paypal.com>",
+      "secondary_email_address": null,
+      "personal_email_address": null,
+      "phone": null,
+      "phone_extension": null,
+      "home_phone": null,
+      "mobile_phone": null,
+      "linkedin_url": null,
+      "title": null,
+      "city": null,
+      "state": null,
+      "country": null,
+      "work_city": null,
+      "work_state": null,
+      "work_country": null,
+      "crm_url": null,
+      "crm_id": null,
+      "account_crm_id": null,
+      "crm_object_type": null,
+      "owner_crm_id": null,
+      "person_company_name": null,
+      "person_company_website": "http://paypal.com",
+      "person_company_industry": null,
+      "do_not_contact": false,
+      "bouncing": false,
+      "locale": null,
+      "locale_utc_offset": null,
+      "eu_resident": false,
+      "personal_website": null,
+      "twitter_handle": null,
+      "last_contacted_type": null,
+      "job_seniority": null,
+      "custom_fields": {},
+      "tags": [],
+      "contact_restrictions": [],
+      "success_count": 0,
+      "starred": false,
+      "untouched": true,
+      "counts": {
+        "emails_sent": 0,
+        "emails_viewed": 0,
+        "emails_clicked": 0,
+        "emails_replied_to": 0,
+        "emails_bounced": 0,
+        "calls": 0
+      },
+      "account": null,
+      "owner": {
+        "_href": "https://api.salesloft.com/v2/users/51550",
+        "id": 51550
+      },
+      "last_contacted_by": null,
+      "import": null,
+      "person_stage": null,
+      "most_recent_cadence": null,
+      "last_completed_step_cadence": null,
+      "last_completed_step": null,
+      "cadences": []
+    },
+    {
+      "id": 164509592,
+      "created_at": "2024-03-07T02:43:31.394443-05:00",
+      "updated_at": "2024-03-07T02:43:31.394443-05:00",
+      "last_contacted_at": null,
+      "last_replied_at": null,
+      "first_name": "Lynnelle",
+      "last_name": "Osbourn",
+      "display_name": "Lynnelle Osbourn",
+      "email_address": "losbourn5@paypal.com",
+      "full_email_address": "\"Lynnelle Osbourn\" <losbourn5@paypal.com>",
+      "secondary_email_address": null,
+      "personal_email_address": null,
+      "phone": null,
+      "phone_extension": null,
+      "home_phone": null,
+      "mobile_phone": null,
+      "linkedin_url": null,
+      "title": null,
+      "city": null,
+      "state": null,
+      "country": null,
+      "work_city": null,
+      "work_state": null,
+      "work_country": null,
+      "crm_url": null,
+      "crm_id": null,
+      "account_crm_id": null,
+      "crm_object_type": null,
+      "owner_crm_id": null,
+      "person_company_name": null,
+      "person_company_website": "http://paypal.com",
+      "person_company_industry": null,
+      "do_not_contact": false,
+      "bouncing": false,
+      "locale": null,
+      "locale_utc_offset": null,
+      "eu_resident": false,
+      "personal_website": null,
+      "twitter_handle": null,
+      "last_contacted_type": null,
+      "job_seniority": null,
+      "custom_fields": {},
+      "tags": [],
+      "contact_restrictions": [],
+      "success_count": 0,
+      "starred": false,
+      "untouched": true,
+      "counts": {
+        "emails_sent": 0,
+        "emails_viewed": 0,
+        "emails_clicked": 0,
+        "emails_replied_to": 0,
+        "emails_bounced": 0,
+        "calls": 0
+      },
+      "account": null,
+      "owner": {
+        "_href": "https://api.salesloft.com/v2/users/51550",
+        "id": 51550
+      },
+      "last_contacted_by": null,
+      "import": null,
+      "person_stage": null,
+      "most_recent_cadence": null,
+      "last_completed_step_cadence": null,
+      "last_completed_step": null,
+      "cadences": []
+    },
+    {
+      "id": 164509591,
+      "created_at": "2024-03-07T02:43:26.305830-05:00",
+      "updated_at": "2024-03-07T02:43:26.305830-05:00",
+      "last_contacted_at": null,
+      "last_replied_at": null,
+      "first_name": "Lynnelle",
+      "last_name": "Osbourn",
+      "display_name": "Lynnelle Osbourn",
+      "email_address": "losbourn4@paypal.com",
+      "full_email_address": "\"Lynnelle Osbourn\" <losbourn4@paypal.com>",
+      "secondary_email_address": null,
+      "personal_email_address": null,
+      "phone": null,
+      "phone_extension": null,
+      "home_phone": null,
+      "mobile_phone": null,
+      "linkedin_url": null,
+      "title": null,
+      "city": null,
+      "state": null,
+      "country": null,
+      "work_city": null,
+      "work_state": null,
+      "work_country": null,
+      "crm_url": null,
+      "crm_id": null,
+      "account_crm_id": null,
+      "crm_object_type": null,
+      "owner_crm_id": null,
+      "person_company_name": null,
+      "person_company_website": "http://paypal.com",
+      "person_company_industry": null,
+      "do_not_contact": false,
+      "bouncing": false,
+      "locale": null,
+      "locale_utc_offset": null,
+      "eu_resident": false,
+      "personal_website": null,
+      "twitter_handle": null,
+      "last_contacted_type": null,
+      "job_seniority": null,
+      "custom_fields": {},
+      "tags": [],
+      "contact_restrictions": [],
+      "success_count": 0,
+      "starred": false,
+      "untouched": true,
+      "counts": {
+        "emails_sent": 0,
+        "emails_viewed": 0,
+        "emails_clicked": 0,
+        "emails_replied_to": 0,
+        "emails_bounced": 0,
+        "calls": 0
+      },
+      "account": null,
+      "owner": {
+        "_href": "https://api.salesloft.com/v2/users/51550",
+        "id": 51550
+      },
+      "last_contacted_by": null,
+      "import": null,
+      "person_stage": null,
+      "most_recent_cadence": null,
+      "last_completed_step_cadence": null,
+      "last_completed_step": null,
+      "cadences": []
+    }
+  ]
+}
\ No newline at end of file
diff --git a/salesloft/test/read-list-users.json b/salesloft/test/read-list-users.json
new file mode 100644
index 00000000..abfe4e1d
--- /dev/null
+++ b/salesloft/test/read-list-users.json
@@ -0,0 +1,65 @@
+{
+  "metadata": {
+    "filtering": {},
+    "sorting": {
+      "sort_by": "id",
+      "sort_direction": "DESC NULLS LAST"
+    }
+  },
+  "data": [
+    {
+      "id": 49067,
+      "guid": "0863ed13-7120-479b-8650-206a3679e2fb",
+      "created_at": "2024-02-20T20:03:20.996772-05:00",
+      "updated_at": "2024-04-27T07:18:23.531659-04:00",
+      "name": "Int User",
+      "first_name": "Int",
+      "last_name": "User",
+      "job_role": null,
+      "active": true,
+      "time_zone": "US/Eastern",
+      "locale_utc_offset": -240,
+      "slack_username": "somebody",
+      "twitter_handle": null,
+      "email": "somebody@withampersand.com",
+      "email_client_email_address": "somebody@withampersand.com",
+      "sending_email_address": "somebody@withampersand.com",
+      "from_address": null,
+      "full_email_address": "\"Int User\" <somebody@withampersand.com>",
+      "bcc_email_address": null,
+      "work_country": null,
+      "seat_package": "premier",
+      "email_signature": "",
+      "email_signature_type": "html",
+      "email_signature_click_tracking_disabled": false,
+      "team_admin": true,
+      "local_dial_enabled": false,
+      "click_to_call_enabled": false,
+      "email_client_configured": false,
+      "crm_connected": false,
+      "external_feature_flags": {
+        "ma_enabled": true,
+        "ma_dev_qa_tools": false,
+        "ma_mobile_workflow": true,
+        "ma_dark_mode": true,
+        "hot_leads": true,
+        "people_crud_allow_create": true,
+        "people_crud_allow_delete": true,
+        "linkedin_oauth_flow": true
+      },
+      "_private_fields": {},
+      "phone_client": {
+        "id": 46885
+      },
+      "phone_number_assignment": null,
+      "group": null,
+      "team": {
+        "_href": "https://api.salesloft.com/v2/team",
+        "id": 111855
+      },
+      "role": {
+        "id": "Admin"
+      }
+    }
+  ]
+}
\ No newline at end of file
diff --git a/salesloft/test/write-create-account.json b/salesloft/test/write-create-account.json
new file mode 100644
index 00000000..4023116c
--- /dev/null
+++ b/salesloft/test/write-create-account.json
@@ -0,0 +1,73 @@
+{
+  "data": {
+    "id": 1,
+    "created_at": "2024-01-01T00:00:00.000000+00:00",
+    "updated_at": "2024-01-01T00:00:00.000000+00:00",
+    "archived_at": "2024-01-01T00:00:00.000000+00:00",
+    "name": "Hogwarts School of Witchcraft and Wizardry",
+    "domain": "salesloft.com",
+    "conversational_name": "Hogwarts",
+    "description": "British school of magic for students",
+    "phone": "+1 444 555 6666",
+    "website": "https://salesloft.com",
+    "linkedin_url": "https://www.linkedin.com/company/2296178/",
+    "twitter_handle": "@kyleporter",
+    "street": "4 Picket Drive",
+    "city": "Dufftown",
+    "state": "Mortlach",
+    "postal_code": "55555",
+    "country": "Scotland",
+    "locale": "Europe/London",
+    "industry": "Education",
+    "company_type": "Private",
+    "founded": "March 1st, 1820",
+    "revenue_range": "1,000,000-2,000,000",
+    "size": "1500",
+    "crm_id": "003i000001mnhpD",
+    "crm_url": "https://na15.salesforce.com/003i000001mnhpD",
+    "crm_object_type": "account",
+    "owner_crm_id": "003i000001mnhpD",
+    "last_contacted_at": "2024-01-01T00:00:00.000000+00:00",
+    "last_contacted_type": "call",
+    "do_not_contact": true,
+    "custom_fields": {
+      "MyField": "A Value",
+      "Other": "Field"
+    },
+    "user_relationships": {
+      "bff": "0037h00000d78aAAAQ",
+      "Other": "_is_null"
+    },
+    "tags": [
+      "7-23-2017",
+      "dreamforce"
+    ],
+    "counts": {
+      "people": 15
+    },
+    "owner": {
+      "id": 1,
+      "_href": "https://api.salesloft.com/v2/users/1"
+    },
+    "creator": {
+      "id": 1,
+      "_href": "https://api.salesloft.com/v2/users/1"
+    },
+    "last_contacted_by": {
+      "id": 1,
+      "_href": "https://api.salesloft.com/v2/users/1"
+    },
+    "last_contacted_person": {
+      "id": 1,
+      "_href": "https://api.salesloft.com/v2/people/1"
+    },
+    "company_stage": {
+      "id": 1,
+      "_href": "https://api.salesloft.com/v2/account_stages/1"
+    },
+    "account_tier": {
+      "id": 1,
+      "_href": "https://api.salesloft.com/v2/account_tiers/1"
+    }
+  }
+}
\ No newline at end of file
diff --git a/salesloft/test/write-create-task.json b/salesloft/test/write-create-task.json
new file mode 100644
index 00000000..0c9e9a5b
--- /dev/null
+++ b/salesloft/test/write-create-task.json
@@ -0,0 +1,43 @@
+{
+  "data": {
+    "id": 175204275,
+    "created_at": "2024-05-01T06:12:41.102458-04:00",
+    "updated_at": "2024-05-01T06:12:41.102458-04:00",
+    "description": null,
+    "due_date": "2025-01-01",
+    "due_at": null,
+    "subject": "call me maybe",
+    "current_state": "scheduled",
+    "task_type": "call",
+    "remind_at": null,
+    "expires_after": null,
+    "completed_at": null,
+    "_integration_id": null,
+    "_integration_step_id": null,
+    "_integration_task_id": null,
+    "_integration_name": null,
+    "_integration_task_type_label": null,
+    "custom_attributes": {},
+    "score": {
+      "score": "0.0",
+      "prioritizer_uuid": "salesloft.prioritizers/rhythm",
+      "factors": {}
+    },
+    "object_references": [],
+    "source": "salesloft.api",
+    "multitouch_group_id": null,
+    "person": {
+      "_href": "https://api.salesloft.com/v2/people/164510523",
+      "id": 164510523
+    },
+    "user": {
+      "_href": "https://api.salesloft.com/v2/users/51550",
+      "id": 51550
+    },
+    "created_by_user": {
+      "_href": "https://api.salesloft.com/v2/users/49067",
+      "id": 49067
+    },
+    "completed_by": null
+  }
+}
\ No newline at end of file
diff --git a/salesloft/test/write-signals-error.json b/salesloft/test/write-signals-error.json
new file mode 100644
index 00000000..cf66d9d5
--- /dev/null
+++ b/salesloft/test/write-signals-error.json
@@ -0,0 +1,7 @@
+{
+  "errors": {
+    "type": [
+      "no Signal Registration found for integration id 5167 and given type"
+    ]
+  }
+}
\ No newline at end of file
diff --git a/salesloft/write_test.go b/salesloft/write_test.go
new file mode 100644
index 00000000..89d42bd0
--- /dev/null
+++ b/salesloft/write_test.go
@@ -0,0 +1,217 @@
+package salesloft
+
+import (
+	"context"
+	"errors"
+	"net/http"
+	"net/http/httptest"
+	"reflect"
+	"strings"
+	"testing"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/interpreter"
+	"github.com/amp-labs/connectors/test/utils/mockutils"
+	"github.com/go-test/deep"
+)
+
+func TestWrite(t *testing.T) { // nolint:funlen,cyclop
+	t.Parallel()
+
+	listSchema := mockutils.DataFromFile(t, "write-signals-error.json")
+	createAccountRes := mockutils.DataFromFile(t, "write-create-account.json")
+	createTaskRes := mockutils.DataFromFile(t, "write-create-task.json")
+
+	tests := []struct {
+		name         string
+		input        common.WriteParams
+		server       *httptest.Server
+		connector    Connector
+		comparator   func(actual, expected *common.WriteResult) bool // custom comparison
+		expected     *common.WriteResult
+		expectedErrs []error
+	}{
+		{
+			name: "Write object must be included",
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.WriteHeader(http.StatusTeapot)
+			})),
+			expectedErrs: []error{common.ErrMissingObjects},
+		},
+		{
+			name:  "Mime response header expected",
+			input: common.WriteParams{ObjectName: "signals"},
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.WriteHeader(http.StatusTeapot)
+			})),
+			expectedErrs: []error{interpreter.ErrMissingContentType},
+		},
+		{
+			name:  "Correct error message is understood from JSON response",
+			input: common.WriteParams{ObjectName: "signals", RecordId: "22165"},
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				w.WriteHeader(http.StatusUnprocessableEntity)
+				_, _ = w.Write(listSchema)
+			})),
+			expectedErrs: []error{
+				common.ErrBadRequest,
+				errors.New("no Signal Registration found for integration id 5167 and given type"), // nolint:goerr113
+			},
+		},
+		{
+			name:  "Write must act as a Create",
+			input: common.WriteParams{ObjectName: "signals"},
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				mockutils.RespondToMethod(w, r, "POST", func() {
+					w.WriteHeader(http.StatusOK)
+				})
+			})),
+			expected:     &common.WriteResult{Success: true},
+			expectedErrs: nil,
+		},
+		{
+			name:  "Write must act as an Update",
+			input: common.WriteParams{ObjectName: "signals", RecordId: "22165"},
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				mockutils.RespondToMethod(w, r, "PUT", func() {
+					w.WriteHeader(http.StatusOK)
+				})
+			})),
+			expected:     &common.WriteResult{Success: true},
+			expectedErrs: nil,
+		},
+		{
+			name:  "Valid creation of account",
+			input: common.WriteParams{ObjectName: "accounts"},
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				mockutils.RespondToMethod(w, r, "POST", func() {
+					w.WriteHeader(http.StatusOK)
+					_, _ = w.Write(createAccountRes)
+				})
+			})),
+			comparator: func(actual, expected *common.WriteResult) bool {
+				return mockutils.WriteResultComparator.SubsetData(actual, expected)
+			},
+			expected: &common.WriteResult{
+				Success:  true,
+				RecordId: "1",
+				Errors:   nil,
+				Data: map[string]any{
+					"id":          1.0,
+					"name":        "Hogwarts School of Witchcraft and Wizardry",
+					"description": "British school of magic for students",
+					"country":     "Scotland",
+					"counts":      map[string]any{"people": 15.0},
+				},
+			},
+			expectedErrs: nil,
+		},
+		{
+			name:  "Valid creation of a task",
+			input: common.WriteParams{ObjectName: "tasks"},
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				mockutils.RespondToMethod(w, r, "POST", func() {
+					w.WriteHeader(http.StatusOK)
+					_, _ = w.Write(createTaskRes)
+				})
+			})),
+			comparator: func(actual, expected *common.WriteResult) bool {
+				return mockutils.WriteResultComparator.SubsetData(actual, expected)
+			},
+			expected: &common.WriteResult{
+				Success:  true,
+				RecordId: "175204275",
+				Errors:   nil,
+				Data: map[string]any{
+					"subject":       "call me maybe",
+					"current_state": "scheduled",
+					"task_type":     "call",
+				},
+			},
+			expectedErrs: nil,
+		},
+		{
+			name:  "Valid update of Saved List View",
+			input: common.WriteParams{ObjectName: "saved_list_views", RecordId: "22463"},
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				mockutils.RespondToMethod(w, r, "PUT", func() {
+					w.WriteHeader(http.StatusOK)
+					mockutils.WriteBody(w, `{"data":{"id":22463,"view":"companies",
+							"name":"Hierarchy overview","view_params":{},"is_default":false,"shared":false}}`)
+				})
+			})),
+			comparator: func(actual, expected *common.WriteResult) bool {
+				return mockutils.WriteResultComparator.SubsetData(actual, expected)
+			},
+			expected: &common.WriteResult{
+				Success:  true,
+				RecordId: "22463",
+				Errors:   nil,
+				Data: map[string]any{
+					"id":   22463.0,
+					"name": "Hierarchy overview",
+					"view": "companies",
+				},
+			},
+			expectedErrs: nil,
+		},
+	}
+
+	for _, tt := range tests { // nolint:dupl
+		// nolint:varnamelen
+		tt := tt // rebind, omit loop side effects for parallel goroutine
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
+			defer tt.server.Close()
+
+			ctx := context.Background()
+
+			connector, err := NewConnector(
+				WithAuthenticatedClient(http.DefaultClient),
+			)
+			if err != nil {
+				t.Fatalf("%s: error in test while constructing connector %v", tt.name, err)
+			}
+
+			// for testing we want to redirect calls to our server
+			connector.setBaseURL(tt.server.URL)
+
+			// start of tests
+			output, err := connector.Write(ctx, tt.input)
+			if len(tt.expectedErrs) == 0 && err != nil {
+				t.Fatalf("%s: expected no errors, got: (%v)", tt.name, err)
+			}
+
+			if len(tt.expectedErrs) != 0 && err == nil {
+				t.Fatalf("%s: expected errors (%v), but got nothing", tt.name, tt.expectedErrs)
+			}
+
+			for _, expectedErr := range tt.expectedErrs {
+				if !errors.Is(err, expectedErr) && !strings.Contains(err.Error(), expectedErr.Error()) {
+					t.Fatalf("%s: expected Error: (%v), got: (%v)", tt.name, expectedErr, err)
+				}
+			}
+
+			// compare desired output
+			var ok bool
+			if tt.comparator == nil {
+				// default comparison is concerned about all fields
+				ok = reflect.DeepEqual(output, tt.expected)
+			} else {
+				ok = tt.comparator(output, tt.expected)
+			}
+
+			if !ok {
+				diff := deep.Equal(output, tt.expected)
+				t.Fatalf("%s:, \nexpected: (%v), \ngot: (%v), \ndiff: (%v)", tt.name, tt.expected, output, diff)
+			}
+		})
+	}
+}
diff --git a/test/microsoftdynamicscrm/README.md b/test/microsoftdynamicscrm/README.md
index 9362eb29..2e59b5c1 100644
--- a/test/microsoftdynamicscrm/README.md
+++ b/test/microsoftdynamicscrm/README.md
@@ -1,6 +1,6 @@
 # Running instructions
 
-## Step 1: prepare "ms-dales-creds.json" file
+## Step 1: prepare "ms-crm-creds.json" file
 
 Create a file called "ms-crm-creds.json" in the root of the project with the following contents
 
diff --git a/test/microsoftdynamicscrm/write-delete/main.go b/test/microsoftdynamicscrm/write-delete/main.go
index c635a649..d751c74b 100644
--- a/test/microsoftdynamicscrm/write-delete/main.go
+++ b/test/microsoftdynamicscrm/write-delete/main.go
@@ -88,7 +88,7 @@ func main() {
 
 func searchLead(res *common.ReadResult, key, value string) map[string]any {
 	for _, data := range res.Data {
-		if data.Fields[key] == value {
+		if compare(data.Fields[key], value) {
 			return data.Fields
 		}
 	}
diff --git a/test/outreach/read/read.go b/test/outreach/metadata/metadata.go
similarity index 73%
rename from test/outreach/read/read.go
rename to test/outreach/metadata/metadata.go
index 0dc8021d..4b4e68fa 100644
--- a/test/outreach/read/read.go
+++ b/test/outreach/metadata/metadata.go
@@ -2,10 +2,9 @@ package main
 
 import (
 	"context"
-	"encoding/json"
+	"fmt"
 	"log"
 	"net/http"
-	"os"
 
 	"github.com/amp-labs/connectors"
 	"github.com/amp-labs/connectors/outreach"
@@ -63,24 +62,18 @@ func GetOutreachConnector(ctx context.Context, filePath string) *outreach.Connec
 }
 
 func main() {
-	outreach := GetOutreachConnector(context.Background(), DefaultCredsFile)
+	objects := []string{"emailAddresses", "users", "ladecima"}
 
-	config := connectors.ReadParams{
-		ObjectName: "users",
-		// NextPage:   "https://api.outreach.io/api/v2/users?page%5Blimit%5D=1\u0026page%5Boffset%5D=2",
-	}
+	ctx := context.Background()
 
-	result, err := outreach.Read(context.Background(), config)
-	if err != nil {
-		log.Fatal(err)
-	}
+	conn := GetOutreachConnector(ctx, "creds.json")
 
-	// Print the results
-	jsonStr, err := json.MarshalIndent(result, "", "  ")
+	m, err := conn.ListObjectMetadata(ctx, objects)
 	if err != nil {
 		log.Fatal(err)
 	}
 
-	_, _ = os.Stdout.Write(jsonStr)
-	_, _ = os.Stdout.WriteString("\n")
+	fmt.Printf("The Object-Metadata: %v\n", m.Result)
+
+	fmt.Printf("The Errors: %v\n", m.Errors)
 }
diff --git a/test/outreach/read-write/read-write.go b/test/outreach/read-write/read-write.go
new file mode 100644
index 00000000..9f79a602
--- /dev/null
+++ b/test/outreach/read-write/read-write.go
@@ -0,0 +1,204 @@
+package main
+
+import (
+	"context"
+	"encoding/json"
+	"log"
+	"net/http"
+	"os"
+
+	"github.com/amp-labs/connectors"
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/outreach"
+	testUtils "github.com/amp-labs/connectors/test/utils"
+	"github.com/amp-labs/connectors/utils"
+	"github.com/brianvoe/gofakeit/v6"
+)
+
+const (
+	DefaultCredsFile = "creds.json"
+)
+
+type Attribute struct {
+	Email     string `json:"email"`
+	EmailType string `json:"emailType"`
+	Order     int    `json:"order"`
+	Status    string `json:"status"`
+}
+
+type EmailAddress struct {
+	Attributes Attribute `json:"attributes"`
+	Type       string    `json:"type"`
+}
+
+type EmailAddressUpdate struct {
+	Attributes Attribute `json:"attributes"`
+	Type       string    `json:"type"`
+	ID         int       `json:"id"` // necessary in updating
+}
+
+func GetOutreachConnector(ctx context.Context, filePath string) *outreach.Connector {
+	registry := utils.NewCredentialsRegistry()
+
+	readers := []utils.Reader{
+		&utils.JSONReader{
+			FilePath: filePath,
+			JSONPath: "$['clientId']",
+			CredKey:  "clientId",
+		},
+		&utils.JSONReader{
+			FilePath: filePath,
+			JSONPath: "$['clientSecret']",
+			CredKey:  "clientSecret",
+		},
+		&utils.JSONReader{
+			FilePath: filePath,
+			JSONPath: "$['refreshToken']",
+			CredKey:  "refreshToken",
+		},
+		&utils.JSONReader{
+			FilePath: filePath,
+			JSONPath: "$['accessToken']",
+			CredKey:  "accessToken",
+		},
+		&utils.JSONReader{
+			FilePath: filePath,
+			JSONPath: "$['provider']",
+			CredKey:  "provider",
+		},
+	}
+	registry.AddReaders(readers...)
+
+	cfg := utils.OutreachOAuthConfigFromRegistry(registry)
+	tok := utils.OutreachOauthTokenFromRegistry(registry)
+
+	conn, err := connectors.Outreach(
+		outreach.WithClient(ctx, http.DefaultClient, cfg, tok),
+	)
+	if err != nil {
+		testUtils.Fail("error creating outreach connector", "error", err)
+	}
+
+	return conn
+}
+
+func main() {
+	var err error
+
+	outreach := GetOutreachConnector(context.Background(), DefaultCredsFile)
+
+	err = testReadConnector(context.Background(), outreach)
+	if err != nil {
+		log.Fatal(err)
+	}
+
+	err = testWriteConnector(context.Background(), outreach)
+	if err != nil {
+		log.Fatal(err)
+	}
+}
+
+func testReadConnector(ctx context.Context, conn connectors.ReadConnector) error {
+	config := connectors.ReadParams{
+		ObjectName: "users",
+		// NextPage:   "https://api.outreach.io/api/v2/users?page%5Blimit%5D=1\u0026page%5Boffset%5D=2",
+	}
+
+	result, err := conn.Read(ctx, config)
+	if err != nil {
+		return err
+	}
+
+	// Print the results
+	jsonStr, err := json.MarshalIndent(result, "", "  ")
+	if err != nil {
+		return err
+	}
+
+	_, _ = os.Stdout.Write(jsonStr)
+	_, _ = os.Stdout.WriteString("\n")
+
+	return nil
+}
+
+func testWriteConnector(ctx context.Context, conn connectors.WriteConnector) error {
+	var err error
+
+	err = testCreate(ctx, conn)
+	if err != nil {
+		return err
+	}
+
+	err = testUpdate(ctx, conn)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func testCreate(ctx context.Context, conn connectors.WriteConnector) error {
+	attribute := Attribute{
+		Email:     gofakeit.Email(),
+		EmailType: "email",
+		Order:     0,
+		Status:    "null",
+	}
+
+	config := common.WriteParams{
+		ObjectName: "emailAddresses",
+		RecordData: EmailAddress{
+			Attributes: attribute,
+			Type:       "emailAddress",
+		},
+	}
+
+	result, err := conn.Write(ctx, config)
+	if err != nil {
+		return err
+	}
+
+	// Print the results
+	jsonStr, err := json.MarshalIndent(result, "", "  ")
+	if err != nil {
+		return err
+	}
+
+	_, _ = os.Stdout.Write(jsonStr)
+	_, _ = os.Stdout.WriteString("\n")
+
+	return nil
+}
+
+func testUpdate(ctx context.Context, conn connectors.WriteConnector) error {
+	patt := Attribute{
+		Email: "groverstiedemann@lehner.io",
+		Order: gofakeit.Number(0, 10),
+	}
+
+	config := common.WriteParams{
+		ObjectName: "emailAddresses",
+		RecordId:   "5",
+		RecordData: EmailAddressUpdate{
+			Attributes: patt,
+			ID:         5,
+			Type:       "emailAddress",
+		},
+	}
+
+	result, err := conn.Write(ctx, config)
+	if err != nil {
+		return err
+	}
+
+	// Print the results
+	jsonStr, err := json.MarshalIndent(result, "", "  ")
+	if err != nil {
+		return err
+	}
+
+	_, _ = os.Stdout.Write(jsonStr)
+	_, _ = os.Stdout.WriteString("\n")
+
+	return nil
+}
diff --git a/test/salesloft/README.md b/test/salesloft/README.md
new file mode 100644
index 00000000..d0aa15d8
--- /dev/null
+++ b/test/salesloft/README.md
@@ -0,0 +1,25 @@
+# Running instructions
+
+## Step 1: prepare "salesloft-creds.json" file
+
+Create a file called "salesloft-creds.json" in the root of the project with the following contents
+
+    e.g. {
+        "CLIENT_ID": "<client id goes here>",
+        "CLIENT_SECRET": "<client secret goes here>",
+        "ACCESS_TOKEN": "<access token goes here>",
+        "REFRESH_TOKEN": "<refresh token goes here>"
+    }
+
+or export to an environment variable SALESLOFT_CRED_FILE by following command
+
+$> export SALESLOFT_CRED_FILE=./salesloft-creds.json # or the path to your salesloft-creds.json file
+
+
+In 1password, you can find a Salesloft creds.json file in the "Shared" vault. TODO this must be in 1password
+Look for the title "Salesloft Sample OAuth Credentials".
+The 1password item has an attached file called "creds.json" that contains the JSON.
+
+## Step 2: run the following command
+
+    $> go run test/salesloft/read/main.go
\ No newline at end of file
diff --git a/test/salesloft/connector.go b/test/salesloft/connector.go
new file mode 100644
index 00000000..e1dc9edc
--- /dev/null
+++ b/test/salesloft/connector.go
@@ -0,0 +1,57 @@
+package salesloft
+
+import (
+	"context"
+	"net/http"
+
+	"github.com/amp-labs/connectors"
+	"github.com/amp-labs/connectors/salesloft"
+	testUtils "github.com/amp-labs/connectors/test/utils"
+	"github.com/amp-labs/connectors/utils"
+)
+
+func GetSalesloftConnector(ctx context.Context, filePath string) *salesloft.Connector {
+	registry := utils.NewCredentialsRegistry()
+
+	readers := []utils.Reader{
+		&utils.JSONReader{
+			FilePath: filePath,
+			JSONPath: "$.CLIENT_ID",
+			CredKey:  "clientId",
+		},
+		&utils.JSONReader{
+			FilePath: filePath,
+			JSONPath: "$.CLIENT_SECRET",
+			CredKey:  "clientSecret",
+		},
+		&utils.JSONReader{
+			FilePath: filePath,
+			JSONPath: "$.ACCESS_TOKEN",
+			CredKey:  "accessToken",
+		},
+		&utils.JSONReader{
+			FilePath: filePath,
+			JSONPath: "$.REFRESH_TOKEN",
+			CredKey:  "refreshToken",
+		},
+		&utils.JSONReader{
+			FilePath: filePath,
+			JSONPath: "$.PROVIDER",
+			CredKey:  "provider",
+		},
+	}
+	_ = registry.AddReaders(readers...)
+
+	cfg := utils.SalesloftConfigFromRegistry(registry)
+	tok := utils.SalesloftTokenFromRegistry(registry)
+
+	conn, err := connectors.Salesloft(
+		salesloft.WithClient(ctx, http.DefaultClient, cfg, tok),
+		salesloft.WithModule(salesloft.DefaultModuleCRM),
+	)
+	if err != nil {
+		testUtils.Fail("error creating Salesloft connector", "error", err)
+	}
+
+	return conn
+}
diff --git a/test/salesloft/read/main.go b/test/salesloft/read/main.go
new file mode 100644
index 00000000..adeb40dc
--- /dev/null
+++ b/test/salesloft/read/main.go
@@ -0,0 +1,49 @@
+package main
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"os/signal"
+	"syscall"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/microsoftdynamicscrm"
+	msTest "github.com/amp-labs/connectors/test/salesloft"
+	"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()
+
+	filePath := os.Getenv("SALESLOFT_CRED_FILE")
+	if filePath == "" {
+		filePath = "./salesloft-creds.json"
+	}
+
+	conn := msTest.GetSalesloftConnector(ctx, filePath)
+	defer utils.Close(conn)
+
+	res, err := conn.Read(ctx, common.ReadParams{
+		ObjectName: "people",
+		Fields: []string{
+			"display_name",
+			"email_address",
+		},
+	})
+	if err != nil {
+		utils.Fail("error reading from Salesloft", "error", err)
+	}
+
+	fmt.Println("Reading people..")
+	utils.DumpJSON(res, os.Stdout)
+
+	if res.Rows > microsoftdynamicscrm.DefaultPageSize {
+		utils.Fail(fmt.Sprintf("expected max %v rows", microsoftdynamicscrm.DefaultPageSize))
+	}
+}
diff --git a/test/salesloft/write-delete/main.go b/test/salesloft/write-delete/main.go
new file mode 100644
index 00000000..7ebfa1b3
--- /dev/null
+++ b/test/salesloft/write-delete/main.go
@@ -0,0 +1,164 @@
+package main
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"os/signal"
+	"syscall"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/salesloft"
+	msTest "github.com/amp-labs/connectors/test/salesloft"
+	"github.com/amp-labs/connectors/test/utils"
+)
+
+type ListViewPayload struct {
+	Name       string `json:"name,omitempty"`
+	View       string `json:"view,omitempty"`
+	ViewParams string `json:"view_params,omitempty"` // JSON object of list view parameters
+	IsDefault  *bool  `json:"is_default,omitempty"`
+	Shared     bool   `json:"shared,omitempty"`
+}
+
+func main() {
+	// Handle Ctrl-C gracefully.
+	ctx, done := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+	defer done()
+
+	// Set up slog logging.
+	utils.SetupLogging()
+
+	filePath := os.Getenv("SALESLOFT_CRED_FILE")
+	if filePath == "" {
+		filePath = "./salesloft-creds.json"
+	}
+
+	conn := msTest.GetSalesloftConnector(ctx, filePath)
+	defer utils.Close(conn)
+
+	fmt.Println("> TEST Create/Update/Delete ListView")
+	fmt.Println("Creating ListView")
+
+	// NOTE: list view must have unique `Name`
+	view := createListView(ctx, conn, &ListViewPayload{
+		Name:       "Tom's Prospects",
+		View:       "companies",
+		ViewParams: "",
+		IsDefault:  boolPtr(true),
+		Shared:     false,
+	})
+
+	fmt.Println("Updating some ListView properties")
+	updateListView(ctx, conn, view.RecordId, &ListViewPayload{
+		Name:      "Jerry's Prospects",
+		View:      "companies",
+		IsDefault: boolPtr(false),
+	})
+
+	fmt.Println("View that ListView has changed accordingly")
+
+	res := readListViews(ctx, conn)
+
+	updatedView := searchListView(res, "id", view.RecordId)
+	for k, v := range map[string]string{
+		"name":       "Jerry's Prospects",
+		"view":       "companies",
+		"is_default": "false",
+		"shared":     "false",
+	} {
+		if !compare(updatedView[k], v) {
+			utils.Fail("error updated properties do not match", k, v, updatedView[k])
+		}
+	}
+
+	fmt.Println("Removing this ListView")
+	removeListView(ctx, conn, view.RecordId)
+	fmt.Println("> Successful test completion")
+}
+
+func searchListView(res *common.ReadResult, key, value string) map[string]any {
+	for _, data := range res.Data {
+		if compare(data.Fields[key], value) {
+			return data.Raw
+		}
+	}
+
+	utils.Fail("error finding ListView")
+
+	return nil
+}
+
+func readListViews(ctx context.Context, conn *salesloft.Connector) *common.ReadResult {
+	res, err := conn.Read(ctx, common.ReadParams{
+		ObjectName: "saved_list_views",
+		Fields: []string{
+			"id", "view", "name",
+		},
+	})
+	if err != nil {
+		utils.Fail("error reading from Salesloft", "error", err)
+	}
+
+	return res
+}
+
+func createListView(ctx context.Context, conn *salesloft.Connector, payload *ListViewPayload) *common.WriteResult {
+	res, err := conn.Write(ctx, common.WriteParams{
+		ObjectName: "saved_list_views",
+		RecordId:   "",
+		RecordData: payload,
+	})
+	if err != nil {
+		utils.Fail("error writing to Salesloft", "error", err)
+	}
+
+	if !res.Success {
+		utils.Fail("failed to create a ListView")
+	}
+
+	return res
+}
+
+func updateListView(ctx context.Context, conn *salesloft.Connector, viewID string, payload *ListViewPayload) *common.WriteResult {
+	res, err := conn.Write(ctx, common.WriteParams{
+		ObjectName: "saved_list_views",
+		RecordId:   viewID,
+		RecordData: payload,
+	})
+	if err != nil {
+		utils.Fail("error writing to Salesloft", "error", err)
+	}
+
+	if !res.Success {
+		utils.Fail("failed to update a ListView")
+	}
+
+	return res
+}
+
+func removeListView(ctx context.Context, conn *salesloft.Connector, viewID string) {
+	res, err := conn.Delete(ctx, common.DeleteParams{
+		ObjectName: "saved_list_views",
+		RecordId:   viewID,
+	})
+	if err != nil {
+		utils.Fail("error deleting for Salesloft", "error", err)
+	}
+
+	if !res.Success {
+		utils.Fail("failed to remove a ListView")
+	}
+}
+
+func boolPtr(b bool) *bool {
+	return &b
+}
+
+func compare(field any, value string) bool {
+	if len(value) == 0 && field == nil {
+		return true
+	}
+
+	return fmt.Sprintf("%v", field) == value
+}
diff --git a/test/salesloft/write-delete/sdk.go b/test/salesloft/write-delete/sdk.go
new file mode 100644
index 00000000..89f4af98
--- /dev/null
+++ b/test/salesloft/write-delete/sdk.go
@@ -0,0 +1,55 @@
+package main
+
+import (
+	"context"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/salesloft"
+	"github.com/amp-labs/connectors/test/utils"
+)
+
+// This file includes methods that capture some READ and WRITE operations of various Salesloft APIs
+// They are not used but are kept for reference.
+
+func createTask(ctx context.Context, conn *salesloft.Connector) {
+	res, err := conn.Write(ctx, common.WriteParams{
+		ObjectName: "tasks",
+		RecordId:   "",
+		RecordData: map[string]any{
+			"subject":   "call me maybe",
+			"task_type": "call",       // call, email, general
+			"due_date":  "2025-01-01", // ISO-8601
+			"user_id":   getFirstUserID(ctx, conn),
+			"person_id": getFirstPersonID(ctx, conn),
+		},
+	})
+	if err != nil {
+		utils.Fail("error writing to Salesloft", "error", err)
+	}
+
+	if !res.Success {
+		utils.Fail("failed to create a Task")
+	}
+}
+
+func getFirstUserID(ctx context.Context, conn *salesloft.Connector) any {
+	res, err := conn.Read(ctx, common.ReadParams{
+		ObjectName: "users",
+	})
+	if err != nil {
+		utils.Fail("error reading from Salesloft", "error", err)
+	}
+
+	return res.Data[0].Raw["id"]
+}
+
+func getFirstPersonID(ctx context.Context, conn *salesloft.Connector) any {
+	res, err := conn.Read(ctx, common.ReadParams{
+		ObjectName: "people",
+	})
+	if err != nil {
+		utils.Fail("error reading from Salesloft", "error", err)
+	}
+
+	return res.Data[0].Raw["id"]
+}
diff --git a/test/utils/mockutils/fileReader.go b/test/utils/mockutils/fileReader.go
index b6b24f33..2f75162c 100644
--- a/test/utils/mockutils/fileReader.go
+++ b/test/utils/mockutils/fileReader.go
@@ -5,12 +5,25 @@ import (
 	"path"
 	"runtime"
 	"strings"
+	"testing"
 )
 
+type FileData []byte
+
 // DataFromFile is used for server mocking.
 // Files must be located under ./test directory relative to the test runner.
-func DataFromFile(testFileName string) ([]byte, error) {
-	_, parentCallerLocation, _, _ := runtime.Caller(1) // nolint:dogsled
+func DataFromFile(t *testing.T, testFileName string) FileData {
+	data, err := internalDataFromFile(testFileName)
+	if err != nil {
+		t.Fatalf("failed to start test, input file missing, %v", err)
+	}
+
+	return data
+}
+
+func internalDataFromFile(testFileName string) (FileData, error) {
+	// NOTE: the deeper the call stack the higher the number should be
+	_, parentCallerLocation, _, _ := runtime.Caller(2) // nolint:dogsled
 
 	workingDir, err := os.Getwd()
 	if err != nil {
diff --git a/test/utils/mockutils/readResult.go b/test/utils/mockutils/readResult.go
new file mode 100644
index 00000000..029d1cf8
--- /dev/null
+++ b/test/utils/mockutils/readResult.go
@@ -0,0 +1,59 @@
+package mockutils
+
+import (
+	"reflect"
+
+	"github.com/amp-labs/connectors/common"
+)
+
+var (
+	ReadResultComparator = readResultComparator{}
+)
+
+type readResultComparator struct{}
+
+// SubsetRaw checks that expected ReadResult.Raw is a subset of actual ReadResult.Raw
+func (readResultComparator) SubsetRaw(actual, expected *common.ReadResult) bool {
+	if len(actual.Data) < len(expected.Data) {
+		return false
+	}
+
+	if len(expected.Data) == 0 || len(expected.Data[0].Raw) == 0 {
+		invalidTest("please specify expected Raw response")
+	}
+
+	for i := range expected.Data {
+		for field := range expected.Data[i].Raw {
+			if !reflect.DeepEqual(actual.Data[i].Raw[field], expected.Data[i].Raw[field]) {
+				return false
+			}
+		}
+	}
+
+	return true
+}
+
+// SubsetFields checks that expected ReadResult.Fields is a subset of actual ReadResult.Fields
+func (readResultComparator) SubsetFields(actual, expected *common.ReadResult) bool {
+	if len(actual.Data) < len(expected.Data) {
+		return false
+	}
+
+	if len(expected.Data) == 0 || len(expected.Data[0].Fields) == 0 {
+		invalidTest("please specify expected Fields response")
+	}
+
+	for i := range expected.Data {
+		for field := range expected.Data[i].Fields {
+			if !reflect.DeepEqual(actual.Data[i].Fields[field], expected.Data[i].Fields[field]) {
+				return false
+			}
+		}
+	}
+
+	return true
+}
+
+func invalidTest(message string) {
+	panic("invalid test, there is no point to check if empty set belongs to any set; " + message)
+}
diff --git a/test/utils/mockutils/server.go b/test/utils/mockutils/server.go
index 9398e258..8b45a00b 100644
--- a/test/utils/mockutils/server.go
+++ b/test/utils/mockutils/server.go
@@ -5,11 +5,18 @@ import (
 	"net/http"
 )
 
-func RespondWithMethodExpectation(w http.ResponseWriter, r *http.Request, methodName string) {
+func RespondNoContentForMethod(w http.ResponseWriter, r *http.Request, methodName string) {
+	RespondToMethod(w, r, methodName, func() {
+		w.WriteHeader(http.StatusNoContent)
+	})
+}
+
+func RespondToMethod(w http.ResponseWriter, r *http.Request, methodName string, onSuccess func()) {
 	// if method is not as expected we return error code so the test will fail
 	// and with response payload which will be a helpful message for debugging
 	if r.Method == methodName {
-		w.WriteHeader(http.StatusNoContent)
+		// if method is matching execute callback
+		onSuccess()
 	} else {
 		w.WriteHeader(http.StatusBadRequest)
 		WriteBody(w, fmt.Sprintf(`{
diff --git a/test/utils/mockutils/writeResult.go b/test/utils/mockutils/writeResult.go
new file mode 100644
index 00000000..cbcc87e5
--- /dev/null
+++ b/test/utils/mockutils/writeResult.go
@@ -0,0 +1,43 @@
+package mockutils
+
+import (
+	"reflect"
+
+	"github.com/amp-labs/connectors/common"
+)
+
+var (
+	WriteResultComparator = writeResultComparator{}
+)
+
+type writeResultComparator struct{}
+
+// SubsetData checks that expected WriteResult.Data is a subset of actual WriteResult.Data
+// other fields are strictly compared.
+func (writeResultComparator) SubsetData(actual, expected *common.WriteResult) bool {
+	if len(actual.Data) < len(expected.Data) {
+		return false
+	}
+
+	// strict comparison
+	ok := actual.Success == expected.Success &&
+		actual.RecordId == expected.RecordId &&
+		reflect.DeepEqual(actual.Errors, expected.Errors)
+
+	if !ok {
+		return false
+	}
+
+	for k, expectedValue := range expected.Data {
+		actualValue, ok := actual.Data[k]
+		if !ok {
+			return false
+		}
+
+		if !reflect.DeepEqual(actualValue, expectedValue) {
+			return false
+		}
+	}
+
+	return true
+}