Repository: amp-labs/connectors
Pull request #174 (2024-04-05 15:42:08)

fix: Add grant types to Box auth provider definition
Added Grant Type to Box connector


Patch:
diff --git a/.gitignore b/.gitignore
index f0950b63..08b01376 100644
--- a/.gitignore
+++ b/.gitignore
@@ -121,4 +121,7 @@ go.work
 playground/
 
 *creds.json
-**/.DS_Store
\ No newline at end of file
+**/.DS_Store
+
+# ssl certificates
+.ssl/
\ No newline at end of file
diff --git a/common/error.go b/common/error.go
index c9f8f18b..4ba45cd3 100644
--- a/common/error.go
+++ b/common/error.go
@@ -31,3 +31,14 @@ func InterpretError(res *http.Response, body []byte) error {
 
 	return NewHTTPStatusError(res.StatusCode, fmt.Errorf("%w: %s", ErrUnknown, string(body)))
 }
+
+func PanicRecovery(wrapup func(cause error)) {
+	if re := recover(); re != nil {
+		err, ok := re.(error)
+		if !ok {
+			panic(re)
+		}
+
+		wrapup(err)
+	}
+}
diff --git a/common/interpreter/error.go b/common/interpreter/error.go
new file mode 100644
index 00000000..a54f94b8
--- /dev/null
+++ b/common/interpreter/error.go
@@ -0,0 +1,37 @@
+package interpreter
+
+import (
+	"errors"
+	"fmt"
+	"mime"
+	"net/http"
+
+	"github.com/amp-labs/connectors/common"
+)
+
+var (
+	ErrUnmarshal          = errors.New("unmarshal failed")
+	ErrMissingContentType = errors.New("mime.ParseMediaType failed")
+)
+
+// FaultyResponseHandler used to parse erroneous response.
+type FaultyResponseHandler func(res *http.Response, body []byte) error
+
+// ErrorHandler invokes a function that matches response media type with parse error, ex: JSON<->JsonParserMethod
+// otherwise defaults to general error interpretation.
+type ErrorHandler struct {
+	JSON FaultyResponseHandler
+}
+
+func (h ErrorHandler) Handle(res *http.Response, body []byte) error {
+	mediaType, _, err := mime.ParseMediaType(res.Header.Get("Content-Type"))
+	if err != nil {
+		return fmt.Errorf("%w: %w", ErrMissingContentType, err)
+	}
+
+	if h.JSON != nil && mediaType == "application/json" {
+		return h.JSON(res, body)
+	}
+
+	return common.InterpretError(res, body)
+}
diff --git a/common/json.go b/common/json.go
index b8e19239..f444b6be 100644
--- a/common/json.go
+++ b/common/json.go
@@ -60,11 +60,6 @@ func (j *JSONHTTPClient) Post(ctx context.Context,
 		return nil, err
 	}
 
-	// empty response body should not be parsed as JSON since it will cause ajson to err
-	if len(body) == 0 {
-		return nil, nil //nolint:nilnil
-	}
-
 	return parseJSONResponse(res, body)
 }
 
@@ -76,9 +71,13 @@ func (j *JSONHTTPClient) Patch(ctx context.Context,
 		return nil, err
 	}
 
-	// empty response body should not be parsed as JSON since it will cause ajson to err
-	if len(body) == 0 {
-		return nil, nil //nolint:nilnil
+	return parseJSONResponse(res, body)
+}
+
+func (j *JSONHTTPClient) Delete(ctx context.Context, url string, headers ...Header) (*JSONHTTPResponse, error) {
+	res, body, err := j.HTTPClient.Delete(ctx, url, addAcceptJSONHeader(headers)) //nolint:bodyclose
+	if err != nil {
+		return nil, err
 	}
 
 	return parseJSONResponse(res, body)
@@ -86,6 +85,10 @@ func (j *JSONHTTPClient) Patch(ctx context.Context,
 
 // parseJSONResponse parses the given HTTP response and returns a JSONHTTPResponse.
 func parseJSONResponse(res *http.Response, body []byte) (*JSONHTTPResponse, error) {
+	// empty response body should not be parsed as JSON since it will cause ajson to err
+	if len(body) == 0 {
+		return nil, nil //nolint:nilnil
+	}
 	// Ensure the response is JSON
 	ct := res.Header.Get("Content-Type")
 	if len(ct) > 0 {
@@ -94,8 +97,11 @@ func parseJSONResponse(res *http.Response, body []byte) (*JSONHTTPResponse, erro
 			return nil, fmt.Errorf("failed to parse content type: %w", err)
 		}
 
-		if mimeType != "application/json" {
-			return nil, fmt.Errorf("%w: expected content type to be application/json, got %s", ErrNotJSON, mimeType)
+		// Providers implementing JSONAPISpeicifcations returns application/vnd.api+json
+		if mimeType != "application/json" && mimeType != "application/vnd.api+json" {
+			return nil, fmt.Errorf("%w: expected content type to be application/json or application/vnd.api+json , got %s",
+				ErrNotJSON, mimeType,
+			)
 		}
 	}
 
diff --git a/common/paramsbuilder/params.go b/common/paramsbuilder/params.go
new file mode 100644
index 00000000..17ec169b
--- /dev/null
+++ b/common/paramsbuilder/params.go
@@ -0,0 +1,102 @@
+package paramsbuilder
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/amp-labs/connectors/common"
+	"golang.org/x/oauth2"
+)
+
+// ParamAssurance checks that param data is valid
+// Every param instance must implement it.
+type ParamAssurance interface {
+	ValidateParams() error
+}
+
+var (
+	ErrMissingClient    = errors.New("http client not set")
+	ErrMissingWorkspace = errors.New("missing workspace name")
+)
+
+// Client params sets up authenticated proxy HTTP client
+// This can be reused among other param builders by composition.
+type Client struct {
+	Caller *common.HTTPClient
+}
+
+func (p *Client) ValidateParams() error {
+	if p.Caller == nil {
+		return ErrMissingClient
+	}
+
+	return nil
+}
+
+func (p *Client) WithClient(
+	ctx context.Context, client *http.Client,
+	config *oauth2.Config, token *oauth2.Token,
+	opts ...common.OAuthOption,
+) {
+	options := []common.OAuthOption{
+		common.WithClient(client),
+		common.WithOAuthConfig(config),
+		common.WithOAuthToken(token),
+	}
+
+	oauthClient, err := common.NewOAuthHTTPClient(ctx, append(options, opts...)...)
+	if err != nil {
+		panic(err) // caught in NewConnector
+	}
+
+	p.WithAuthenticatedClient(oauthClient)
+}
+
+func (p *Client) WithAuthenticatedClient(client common.AuthenticatedHTTPClient) {
+	p.Caller = &common.HTTPClient{
+		Client:       client,
+		ErrorHandler: common.InterpretError,
+	}
+}
+
+// Workspace params sets up varying workspace name.
+type Workspace struct {
+	Name string
+}
+
+func (p *Workspace) ValidateParams() error {
+	if len(p.Name) == 0 {
+		return ErrMissingWorkspace
+	}
+
+	return nil
+}
+
+func (p *Workspace) WithWorkspace(workspaceRef string) {
+	p.Name = workspaceRef
+}
+
+// Module params adds suffix to URL controlling API versions.
+type Module struct {
+	Suffix string
+}
+
+func (p *Module) ValidateParams() error {
+	// url suffix may be omitted
+	return nil
+}
+
+func (p *Module) WithModule(module APIModule) {
+	p.Suffix = module.String()
+}
+
+type APIModule struct {
+	Label   string // e.g. "crm"
+	Version string // e.g. "v3"
+}
+
+func (a APIModule) String() string {
+	return fmt.Sprintf("%s/%s", a.Label, a.Version)
+}
diff --git a/common/parse.go b/common/parse.go
index a8a58218..b24dd65e 100644
--- a/common/parse.go
+++ b/common/parse.go
@@ -1,6 +1,8 @@
 package common
 
 import (
+	"errors"
+	"math"
 	"strings"
 
 	"github.com/spyzhov/ajson"
@@ -73,3 +75,105 @@ 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/types.go b/common/types.go
index cba27e31..3aa6f67e 100644
--- a/common/types.go
+++ b/common/types.go
@@ -43,6 +43,9 @@ var (
 	// ErrNotJSON is returned when a response is not JSON.
 	ErrNotJSON = errors.New("response is not JSON")
 
+	// ErrNotXML is returned when a response is not XML.
+	ErrNotXML = errors.New("response is not XML")
+
 	// ErrMissingOauthConfig is returned when the OAuth config is missing.
 	ErrMissingOauthConfig = errors.New("missing OAuth config")
 
@@ -58,6 +61,9 @@ var (
 	// ErrMissingObjects is returned when no objects are provided in the request.
 	ErrMissingObjects = errors.New("no objects provided")
 
+	// ErrMissingRecordID is returned when resource id is missing in the request.
+	ErrMissingRecordID = errors.New("no object ID provided")
+
 	// ErrInvalidPathJoin is returned when the path join is invalid.
 	ErrInvalidPathJoin = errors.New("invalid path join")
 
@@ -81,7 +87,7 @@ type ReadParams struct {
 	// The fields we are reading from the object, e.g. ["Id", "Name", "BillingCity"]
 	Fields []string // required, at least one field needed
 	// NextPage is an opaque token that can be used to get the next page of results.
-	NextPage string // optional, only set this if you want to read the next page of results
+	NextPage NextPageToken // optional, only set this if you want to read the next page of results
 	// Since is a timestamp that can be used to get only records that have changed since that time.
 	Since time.Time // optional, omit this to fetch all records
 	// Deleted is true if we want to read deleted records instead of active records.
@@ -101,12 +107,25 @@ type WriteParams struct {
 	RecordData any // required
 }
 
+// DeleteParams defines how we are deleting data in SaaS API.
+type DeleteParams struct {
+	// The name of the object we are deleting, e.g. "Account"
+	ObjectName string // required
+
+	// The external ID of the object instance we are removing.
+	RecordId string // required
+}
+
 // NextPageToken is an opaque token that can be used to get the next page of results.
 // Callers are encouraged to treat this as an opaque string, and not attempt to parse it.
 // And although each provider will be different, callers should expect that this token
 // will expire after some period of time. So long-term storage of this token is not recommended.
 type NextPageToken string
 
+func (t NextPageToken) String() string {
+	return string(t)
+}
+
 // ReadResult is what's returned from reading data via the Read call.
 type ReadResult struct {
 	// Rows is the number of total rows in the result.
@@ -141,6 +160,12 @@ type WriteResult struct {
 	Data map[string]interface{} `json:"data,omitempty"` // optional
 }
 
+// DeleteResult is what's returned from deleting data via the Delete call.
+type DeleteResult struct {
+	// Success is true if deletion succeeded.
+	Success bool `json:"success"`
+}
+
 // NewHTTPStatusError creates a new error with the given HTTP status.
 func NewHTTPStatusError(status int, err error) error {
 	if status < 1 || status > 599 {
diff --git a/common/xml.go b/common/xml.go
index 044bc52a..ba716a03 100644
--- a/common/xml.go
+++ b/common/xml.go
@@ -1,12 +1,17 @@
 package common
 
 import (
+	"bytes"
+	"context"
 	"encoding/json"
 	"errors"
 	"fmt"
+	"mime"
+	"net/http"
 	"strings"
 
 	"github.com/go-playground/validator"
+	"github.com/subchen/go-xmldom"
 )
 
 const (
@@ -18,11 +23,93 @@ const (
 var (
 	//nolint:gochecknoglobals
 	validate          = validator.New()
+	ErrNoXMLRoot      = errors.New("xml document has no root")
 	ErrNotXMLChildren = errors.New("children must be of type 'XMLData' or 'XMLString'")
 	ErrNoSelfClosing  = errors.New("selfClosing cannot be true if children are not present")
 	ErrNoParens       = errors.New("value cannot contain < or >")
 )
 
+// XMLHTTPClient is an HTTP client that can parse XML response.
+type XMLHTTPClient struct {
+	HTTPClient *HTTPClient // underlying HTTP client. Required.
+}
+
+type XMLHTTPResponse struct {
+	// bodyBytes is the raw response body.
+	bodyBytes []byte
+
+	// Code is the HTTP status code of the response.
+	Code int
+
+	// Headers are the HTTP headers of the response.
+	Headers http.Header
+
+	// Body is the unmarshalled response body in XML form. Content is the same as bodyBytes
+	Body *xmldom.Document
+}
+
+func (r XMLHTTPResponse) GetRoot() (*xmldom.Node, error) {
+	if r.Body == nil || r.Body.Root == nil {
+		return nil, ErrNoXMLRoot
+	}
+
+	return r.Body.Root, nil
+}
+
+// Get makes a GET request to the given URL and returns the response body as a XML object.
+// If the response is not a 2xx, an error is returned. If the response is a 401, the caller should
+// refresh the access token and retry the request. If errorHandler is nil, then the default error
+// handler is used. If not, the caller can inject their own error handling logic.
+func (j *XMLHTTPClient) Get(ctx context.Context, url string, headers ...Header) (*XMLHTTPResponse, error) {
+	res, body, err := j.HTTPClient.Get(ctx, url, addAcceptXMLHeader(headers)) //nolint:bodyclose
+	if err != nil {
+		return nil, err
+	}
+
+	return parseXMLResponse(res, body)
+}
+
+// parseXMLResponse parses the given HTTP response and returns a XMLHTTPResponse.
+func parseXMLResponse(res *http.Response, body []byte) (*XMLHTTPResponse, error) {
+	if len(body) == 0 {
+		// Empty XML response is not allowed
+		return nil, ErrNotXML
+	}
+	// Ensure the response is XML
+	ct := res.Header.Get("Content-Type")
+	if len(ct) > 0 {
+		mimeType, _, err := mime.ParseMediaType(ct)
+		if err != nil {
+			return nil, fmt.Errorf("failed to parse content type: %w", err)
+		}
+
+		if mimeType != "application/xml" {
+			return nil, fmt.Errorf("%w: expected content type to be application/xml, got %s", ErrNotXML, mimeType)
+		}
+	}
+
+	// Unmarshall the response body into XML
+	xmlBody, err := xmldom.Parse(bytes.NewReader(body))
+	if err != nil {
+		return nil, NewHTTPStatusError(res.StatusCode, fmt.Errorf("failed to unmarshall response body into XML: %w", err))
+	}
+
+	return &XMLHTTPResponse{
+		bodyBytes: body,
+		Code:      res.StatusCode,
+		Headers:   res.Header,
+		Body:      xmlBody,
+	}, nil
+}
+
+func addAcceptXMLHeader(headers []Header) []Header {
+	if headers == nil {
+		headers = make([]Header, 0)
+	}
+
+	return append(headers, Header{Key: "Accept", Value: "application/xml"})
+}
+
 type XMLSchema interface {
 	String() string
 	Validate() error
@@ -166,12 +253,12 @@ func (x *XMLData) String() string {
 
 	end := x.endTag()
 
-	chilren := []string{}
+	children := make([]string, 0)
 	for _, child := range x.Children {
-		chilren = append(chilren, child.String())
+		children = append(children, child.String())
 	}
 
-	return fmt.Sprintf("%s%s%s", start, strings.Join(chilren, ""), end)
+	return fmt.Sprintf("%s%s%s", start, strings.Join(children, ""), end)
 }
 
 func (x *XMLData) startTag() string {
@@ -182,19 +269,19 @@ func (x *XMLData) startTag() string {
 
 	attrStr := strings.Join(attributes, " ")
 
-	var close string //nolint:predeclared
+	var closingTag string //nolint:predeclared
 
 	if x.SelfClosing {
-		close = closeParenWithSlash
+		closingTag = closeParenWithSlash
 	} else {
-		close = closeParen
+		closingTag = closeParen
 	}
 
 	if attrStr == "" {
-		return fmt.Sprintf("%s%s%s", openParen, x.XMLName, close)
+		return fmt.Sprintf("%s%s%s", openParen, x.XMLName, closingTag)
 	}
 
-	return fmt.Sprintf("%s%s %s%s", openParen, x.XMLName, attrStr, close)
+	return fmt.Sprintf("%s%s %s%s", openParen, x.XMLName, attrStr, closingTag)
 }
 
 func (x *XMLData) endTag() string {
diff --git a/basic/client.go b/connector/client.go
similarity index 65%
rename from basic/client.go
rename to connector/client.go
index 4bab3a3a..6aaee506 100644
--- a/basic/client.go
+++ b/connector/client.go
@@ -1,9 +1,13 @@
-package basic
+package connector
 
 import (
 	"github.com/amp-labs/connectors/common"
 )
 
+func (c *Connector) JSONHTTPClient() *common.JSONHTTPClient {
+	return c.Client
+}
+
 func (c *Connector) HTTPClient() *common.HTTPClient {
 	return c.Client.HTTPClient
 }
diff --git a/basic/connector.go b/connector/connector.go
similarity index 90%
rename from basic/connector.go
rename to connector/connector.go
index fd70bbf6..31a181ca 100644
--- a/basic/connector.go
+++ b/connector/connector.go
@@ -1,18 +1,16 @@
-package basic
+package connector
 
 import (
 	"github.com/amp-labs/connectors/common"
 	"github.com/amp-labs/connectors/providers"
 )
 
-// Connector is a Basic connector.
 type Connector struct {
 	ProviderInfo *providers.ProviderInfo
 	Client       *common.JSONHTTPClient
 	provider     providers.Provider
 }
 
-// NewConnector returns a new Basic connector.
 func NewConnector(
 	provider providers.Provider,
 	opts ...Option,
@@ -30,7 +28,7 @@ func NewConnector(
 	}()
 
 	// Set up basic params
-	params := &basicParams{}
+	params := &connectorParams{}
 	params.provider = provider
 
 	// Apply options & verify
diff --git a/basic/errors.go b/connector/errors.go
similarity index 91%
rename from basic/errors.go
rename to connector/errors.go
index b14c12ae..411efe71 100644
--- a/basic/errors.go
+++ b/connector/errors.go
@@ -1,4 +1,4 @@
-package basic
+package connector
 
 import (
 	"net/http"
diff --git a/basic/params.go b/connector/params.go
similarity index 87%
rename from basic/params.go
rename to connector/params.go
index c0b9ac1e..94a31209 100644
--- a/basic/params.go
+++ b/connector/params.go
@@ -1,4 +1,4 @@
-package basic
+package connector
 
 import (
 	"context"
@@ -18,15 +18,15 @@ var (
 	ErrMissingProvider = errors.New("missing provider")
 )
 
-type Option func(*basicParams)
+type Option func(*connectorParams)
 
-type basicParams struct {
+type connectorParams struct {
 	provider      providers.Provider
 	client        *common.JSONHTTPClient
 	substitutions map[string]string
 }
 
-func (p *basicParams) prepare() (*basicParams, error) {
+func (p *connectorParams) prepare() (*connectorParams, error) {
 	if p.provider == "" {
 		return nil, ErrMissingProvider
 	}
@@ -42,7 +42,7 @@ func (p *basicParams) prepare() (*basicParams, error) {
 // reading from providers.yaml. If the provider values are not set, the connector
 // will use error out.
 func WithCatalogSubstitutions(substitutions map[string]string) Option {
-	return func(params *basicParams) {
+	return func(params *connectorParams) {
 		params.substitutions = substitutions
 	}
 }
@@ -51,7 +51,7 @@ func WithCatalogSubstitutions(substitutions map[string]string) Option {
 func WithClient(ctx context.Context, client *http.Client, config *oauth2.Config, token *oauth2.Token,
 	opts ...common.OAuthOption,
 ) Option {
-	return func(params *basicParams) {
+	return func(params *connectorParams) {
 		options := []common.OAuthOption{
 			common.WithClient(client),
 			common.WithOAuthConfig(config),
@@ -69,7 +69,7 @@ func WithClient(ctx context.Context, client *http.Client, config *oauth2.Config,
 
 // WithAuthenticatedClient sets the http client to use for the connector. Its usage is optional.
 func WithAuthenticatedClient(client common.AuthenticatedHTTPClient) Option {
-	return func(params *basicParams) {
+	return func(params *connectorParams) {
 		params.client = &common.JSONHTTPClient{
 			HTTPClient: &common.HTTPClient{
 				Client:       client,
diff --git a/basic/provider.go b/connector/provider.go
similarity index 87%
rename from basic/provider.go
rename to connector/provider.go
index 6f33a72d..f32e5eb4 100644
--- a/basic/provider.go
+++ b/connector/provider.go
@@ -1,4 +1,4 @@
-package basic
+package connector
 
 import (
 	"github.com/amp-labs/connectors/providers"
diff --git a/connector/string.go b/connector/string.go
new file mode 100644
index 00000000..d1c56325
--- /dev/null
+++ b/connector/string.go
@@ -0,0 +1,5 @@
+package connector
+
+func (c *Connector) String() string {
+	return c.Provider() + ".Connector"
+}
diff --git a/connectors.go b/connectors.go
index c730df24..48e8550d 100644
--- a/connectors.go
+++ b/connectors.go
@@ -5,19 +5,28 @@ import (
 	"errors"
 	"fmt"
 	"io"
+	"net/http"
 
 	"github.com/amp-labs/connectors/common"
 	"github.com/amp-labs/connectors/hubspot"
+	"github.com/amp-labs/connectors/microsoftdynamicscrm"
+	"github.com/amp-labs/connectors/mock"
+	"github.com/amp-labs/connectors/outreach"
 	"github.com/amp-labs/connectors/providers"
 	"github.com/amp-labs/connectors/salesforce"
 )
 
-// BasicConnector is an interface that can be used to implement a connector with
+// Connector is an interface that can be used to implement a connector with
 // basic configuration about the provider.
-type BasicConnector interface {
+type Connector interface {
 	fmt.Stringer
 	io.Closer
 
+	// JSONHTTPClient returns the underlying JSON HTTP client. This is useful for
+	// testing, or for calling methods that aren't exposed by the Connector
+	// interface directly. Authentication and token refreshes will be handled automatically.
+	JSONHTTPClient() *common.JSONHTTPClient
+
 	// HTTPClient returns the underlying HTTP client. This is useful for proxy requests.
 	HTTPClient() *common.HTTPClient
 
@@ -25,9 +34,9 @@ type BasicConnector interface {
 	Provider() providers.Provider
 }
 
-// Connector is an interface that all connectors must implement.
-type Connector interface {
-	BasicConnector
+// ReadConnector is an interface that extends the Connector interface with read capabilities.
+type ReadConnector interface {
+	Connector
 
 	// Read reads a page of data from the connector. This can be called multiple
 	// times to read all the data. The caller is responsible for paging, by
@@ -36,15 +45,28 @@ type Connector interface {
 	// Authentication corner cases are handled internally, but all other errors
 	// are returned to the caller.
 	Read(ctx context.Context, params ReadParams) (*ReadResult, error)
+}
+
+// WriteConnector is an interface that extends the Connector interface with write capabilities.
+type WriteConnector interface {
+	Connector
 
 	Write(ctx context.Context, params WriteParams) (*WriteResult, error)
+}
 
-	ListObjectMetadata(ctx context.Context, objectNames []string) (*ListObjectMetadataResult, error)
+// DeleteConnector is an interface that extends the Connector interface with delete capabilities.
+type DeleteConnector interface {
+	Connector
 
-	// JSONHTTPClient returns the underlying JSON HTTP client. This is useful for
-	// testing, or for calling methods that aren't exposed by the Connector
-	// interface directly. Authentication and token refreshes will be handled automatically.
-	JSONHTTPClient() *common.JSONHTTPClient
+	Delete(ctx context.Context, params DeleteParams) (*DeleteResult, error)
+}
+
+// ObjectMetadataConnector is an interface that extends the Connector interface with
+// the ability to list object metadata.
+type ObjectMetadataConnector interface {
+	Connector
+
+	ListObjectMetadata(ctx context.Context, objectNames []string) (*ListObjectMetadataResult, error)
 }
 
 // API is a function that returns a Connector. It's used as a factory.
@@ -65,12 +87,23 @@ var Salesforce API[*salesforce.Connector, salesforce.Option] = salesforce.NewCon
 // Hubspot is an API that returns a new Hubspot Connector.
 var Hubspot API[*hubspot.Connector, hubspot.Option] = hubspot.NewConnector //nolint:gochecknoglobals
 
+// MSDynamicsCRM is an API that returns a new MS Dynamics 365 Sales Connector.
+var MSDynamicsCRM API[*microsoftdynamicscrm.Connector, microsoftdynamicscrm.Option] = microsoftdynamicscrm.NewConnector //nolint:gochecknoglobals,lll
+
+// Mock is an API that returns a new Mock Connector.
+var Mock API[*mock.Connector, mock.Option] = mock.NewConnector //nolint:gochecknoglobals
+
+// Outreach is an API that returns a new Outreach Connector.
+var Outreach API[*outreach.Connector, outreach.Option] = outreach.NewConnector //nolint:gochecknoglobals
+
 // We re-export the following types so that they can be used by consumers of this library.
 type (
 	ReadParams               = common.ReadParams
 	WriteParams              = common.WriteParams
+	DeleteParams             = common.DeleteParams
 	ReadResult               = common.ReadResult
 	WriteResult              = common.WriteResult
+	DeleteResult             = common.DeleteResult
 	ListObjectMetadataResult = common.ListObjectMetadataResult
 
 	ErrorWithStatus = common.HTTPStatusError
@@ -103,15 +136,52 @@ var (
 // since you get type safety and more readable code.
 func New(provider providers.Provider, opts map[string]any) (Connector, error) { //nolint:ireturn
 	switch provider {
+	case providers.Mock:
+		return newMock(opts)
 	case providers.Salesforce:
 		return newSalesforce(opts)
 	case providers.Hubspot:
 		return newHubspot(opts)
+	case providers.Outreach:
+		return newOutreach(opts)
 	default:
 		return nil, fmt.Errorf("%w: %s", ErrUnknownConnector, provider)
 	}
 }
 
+// newMock returns a new Mock Connector, by unwrapping the options and passing them to the Mock API.
+func newMock(opts map[string]any) (Connector, error) { //nolint:ireturn
+	var options []mock.Option
+
+	c, valid := getParam[*http.Client](opts, "client")
+	if valid {
+		options = append(options, mock.WithClient(c))
+	}
+
+	a, valid := getParam[common.AuthenticatedHTTPClient](opts, "authenticated-client")
+	if valid {
+		options = append(options, mock.WithAuthenticatedClient(a))
+	}
+
+	r, valid := getParam[func(ctx context.Context, params ReadParams) (*ReadResult, error)](opts, "read")
+	if valid {
+		options = append(options, mock.WithRead(r))
+	}
+
+	w, valid := getParam[func(ctx context.Context, params WriteParams) (*WriteResult, error)](opts, "write")
+	if valid {
+		options = append(options, mock.WithWrite(w))
+	}
+
+	l, valid := getParam[func(ctx context.Context, objectNames []string) (*ListObjectMetadataResult, error)](
+		opts, "list-object-metadata")
+	if valid {
+		options = append(options, mock.WithListObjectMetadata(l))
+	}
+
+	return Mock.New(options...)
+}
+
 // newSalesforce returns a new Salesforce Connector, by unwrapping the options and passing them to the Salesforce API.
 func newSalesforce(opts map[string]any) (Connector, error) { //nolint:ireturn
 	var options []salesforce.Option
@@ -146,6 +216,18 @@ func newHubspot(opts map[string]any) (Connector, error) { //nolint:ireturn
 	return Hubspot.New(options...)
 }
 
+// newOutreach returns a new Outreach Connector, by unwrapping the options and passing them to the Outreach API.
+func newOutreach(opts map[string]any) (Connector, error) { //nolint:ireturn
+	var options []outreach.Option
+
+	c, valid := getParam[common.AuthenticatedHTTPClient](opts, "client")
+	if valid {
+		options = append(options, outreach.WithAuthenticatedClient(c))
+	}
+
+	return Outreach.New(options...)
+}
+
 // getParam returns the value of the given key, if present, safely cast to an assumed type.
 // If the key is not present, or the value is not of the assumed type, it returns the
 // zero value of the desired type, and false. In case of success, it returns the value and true.
diff --git a/go.mod b/go.mod
index 7ec225df..6fcbe969 100644
--- a/go.mod
+++ b/go.mod
@@ -5,11 +5,12 @@ go 1.21
 require (
 	github.com/brianvoe/gofakeit/v6 v6.28.0
 	github.com/go-playground/validator v9.31.0+incompatible
+	github.com/go-test/deep v1.1.0
 	github.com/joho/godotenv v1.5.1
 	github.com/spyzhov/ajson v0.9.1
 	github.com/stretchr/testify v1.9.0
 	github.com/subchen/go-xmldom v1.1.2
-	golang.org/x/oauth2 v0.19.0
+	golang.org/x/oauth2 v0.20.0
 )
 
 require (
diff --git a/go.sum b/go.sum
index b0499960..b2b81273 100644
--- a/go.sum
+++ b/go.sum
@@ -10,6 +10,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
 github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
 github.com/go-playground/validator v9.31.0+incompatible h1:UA72EPEogEnq76ehGdEDp4Mit+3FDh548oRqwVgNsHA=
 github.com/go-playground/validator v9.31.0+incompatible/go.mod h1:yrEkQXlcI+PugkyDjY2bRrL/UBU4f3rvrgkN3V8JEig=
+github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg=
+github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
 github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
 github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
 github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
@@ -24,8 +26,8 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT
 github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
 github.com/subchen/go-xmldom v1.1.2 h1:7evI2YqfYYOnuj+PBwyaOZZYjl3iWq35P6KfBUw9jeU=
 github.com/subchen/go-xmldom v1.1.2/go.mod h1:6Pg/HuX5/T4Jlj0IPJF1sRxKVoI/rrKP6LIMge9d5/8=
-golang.org/x/oauth2 v0.19.0 h1:9+E/EZBCbTLNrbN35fHv/a/d/mOBatymz1zbtQrXpIg=
-golang.org/x/oauth2 v0.19.0/go.mod h1:vYi7skDa1x015PmRRYZ7+s1cWyPgrPiSYRe4rnsexc8=
+golang.org/x/oauth2 v0.20.0 h1:4mQdhULixXKP1rwYBW0vAijoXnkTG0BLCDRzfe1idMo=
+golang.org/x/oauth2 v0.20.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/go-playground/assert.v1 v1.2.1 h1:xoYuJVE7KT85PYWrN730RguIQO0ePzVRfFMXadIrXTM=
diff --git a/hubspot/modules.go b/hubspot/modules.go
index a8198bbe..d90f8d0d 100644
--- a/hubspot/modules.go
+++ b/hubspot/modules.go
@@ -21,6 +21,20 @@ var ModuleEmpty = APIModule{ // nolint: gochecknoglobals
 	Version: "",
 }
 
+// supportedModules represents currently working and supported modules within the Hubspot connector.
+// Any added module should be appended added here.
+var supportedModules = []APIModule{ModuleCRM} // nolint: gochecknoglobals
+
 func (a APIModule) String() string {
 	return fmt.Sprintf("%s/%s", a.Label, a.Version)
 }
+
+func supportsModule(module string) bool {
+	for _, mod := range supportedModules {
+		if module == mod.String() {
+			return true
+		}
+	}
+
+	return false
+}
diff --git a/hubspot/params.go b/hubspot/params.go
index fabe06f3..bd2fc4a1 100644
--- a/hubspot/params.go
+++ b/hubspot/params.go
@@ -69,6 +69,12 @@ func (p *hubspotParams) prepare() (out *hubspotParams, err error) {
 		return nil, ErrMissingClient
 	}
 
+	// making sure the provided module is supported.
+	// If the provide module is not supprted. defaults to CRM.
+	if !supportsModule(p.module) {
+		p.module = ModuleCRM.String()
+	}
+
 	return p, nil
 }
 
diff --git a/hubspot/read.go b/hubspot/read.go
index 58ac90f1..9538e2a1 100644
--- a/hubspot/read.go
+++ b/hubspot/read.go
@@ -49,7 +49,7 @@ func (c *Connector) Read(ctx context.Context, config common.ReadParams) (*common
 	if len(config.NextPage) > 0 {
 		// If NextPage is set, then we're reading the next page of results.
 		// All that matters is the NextPage URL, the fields are ignored.
-		rsp, err = c.get(ctx, config.NextPage)
+		rsp, err = c.get(ctx, config.NextPage.String())
 	} else {
 		// If NextPage is not set, then we're reading the first page of results.
 		// We need to construct the query and then make the request.
diff --git a/hubspot/types.go b/hubspot/types.go
index 5827ca62..a326a25a 100644
--- a/hubspot/types.go
+++ b/hubspot/types.go
@@ -1,10 +1,12 @@
 package hubspot
 
+import "github.com/amp-labs/connectors/common"
+
 type SearchParams struct {
 	// The name of the object we are reading, e.g. "Account"
 	ObjectName string // required
 	// NextPage is an opaque token that can be used to get the next page of results.
-	NextPage string // optional, only set this if you want to read the next page of results
+	NextPage common.NextPageToken // optional, only set this if you want to read the next page of results
 	// SortBy is the field to sort by in the direction specified by SortDirection.
 	SortBy []SortBy // optional
 	// FilterBy is the filter to apply to the search
diff --git a/microsoftdynamicscrm/client.go b/microsoftdynamicscrm/client.go
new file mode 100644
index 00000000..e35e0e97
--- /dev/null
+++ b/microsoftdynamicscrm/client.go
@@ -0,0 +1,16 @@
+package microsoftdynamicscrm
+
+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/microsoftdynamicscrm/connector.go b/microsoftdynamicscrm/connector.go
new file mode 100644
index 00000000..82ee2d4b
--- /dev/null
+++ b/microsoftdynamicscrm/connector.go
@@ -0,0 +1,86 @@
+package microsoftdynamicscrm
+
+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/providers"
+)
+
+var DefaultModuleCRM = paramsbuilder.APIModule{ // nolint: gochecknoglobals
+	Label:   "api/data",
+	Version: "v9.2",
+}
+
+type Connector struct {
+	BaseURL   string
+	Module    string
+	Client    *common.JSONHTTPClient
+	XMLClient *common.XMLHTTPClient
+}
+
+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.MicrosoftDynamics365CRM, &map[string]string{
+		"workspace": params.Workspace.Name,
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	httpClient := params.Client.Caller
+	conn = &Connector{
+		Module: params.Module.Suffix,
+		Client: &common.JSONHTTPClient{
+			HTTPClient: httpClient,
+		},
+		XMLClient: &common.XMLHTTPClient{
+			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.MicrosoftDynamics365CRM
+}
+
+func (c *Connector) String() string {
+	return fmt.Sprintf("%s.Connector[%s]", c.Provider(), c.Module)
+}
+
+func (c *Connector) getURL(arg string) string {
+	parts := []string{c.BaseURL, c.Module, arg}
+	filtered := make([]string, 0)
+
+	for _, part := range parts {
+		if len(part) != 0 {
+			filtered = append(filtered, part)
+		}
+	}
+
+	return strings.Join(filtered, "/")
+}
+
+func (c *Connector) setBaseURL(newURL string) {
+	c.BaseURL = newURL
+	c.Client.HTTPClient.Base = newURL
+}
diff --git a/microsoftdynamicscrm/delete.go b/microsoftdynamicscrm/delete.go
new file mode 100644
index 00000000..ec5f7ae3
--- /dev/null
+++ b/microsoftdynamicscrm/delete.go
@@ -0,0 +1,31 @@
+package microsoftdynamicscrm
+
+import (
+	"context"
+	"fmt"
+
+	"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
+	}
+
+	// resource id must be enclosed in brackets
+	url := c.getURL(fmt.Sprintf("%v(%v)", config.ObjectName, config.RecordId))
+
+	// 204 NoContent is expected
+	_, err := c.delete(ctx, url)
+	if err != nil {
+		return nil, err
+	}
+
+	return &common.DeleteResult{
+		Success: true,
+	}, nil
+}
diff --git a/microsoftdynamicscrm/errors.go b/microsoftdynamicscrm/errors.go
new file mode 100644
index 00000000..31e5be23
--- /dev/null
+++ b/microsoftdynamicscrm/errors.go
@@ -0,0 +1,66 @@
+package microsoftdynamicscrm
+
+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
+	apiError := &CRMResponseError{}
+	if err := json.Unmarshal(body, &apiError); err != nil {
+		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)
+	}
+}
+
+type CRMResponseError struct {
+	Err CRMError `json:"error"`
+}
+
+type CRMError struct {
+	Code    string `json:"code"`
+	Message string `json:"message"`
+	*EnhancedCRMError
+}
+
+type EnhancedCRMError struct {
+	HelpLink     string `json:"@Microsoft.PowerApps.CDS.HelpLink"`           // nolint:tagliatelle
+	TraceText    string `json:"@Microsoft.PowerApps.CDS.TraceText"`          // nolint:tagliatelle
+	InnerMessage string `json:"@Microsoft.PowerApps.CDS.InnerError.Message"` // nolint:tagliatelle
+}
+
+func createError(base error, response *CRMResponseError) error {
+	if len(response.Err.Message) > 0 {
+		return fmt.Errorf("%w: %s", base, response.Err.Message)
+	}
+
+	return base
+}
diff --git a/microsoftdynamicscrm/metadata.go b/microsoftdynamicscrm/metadata.go
new file mode 100644
index 00000000..beddf849
--- /dev/null
+++ b/microsoftdynamicscrm/metadata.go
@@ -0,0 +1,127 @@
+package microsoftdynamicscrm
+
+import (
+	"context"
+	"errors"
+	"fmt"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/subchen/go-xmldom"
+)
+
+var (
+	CRMMetadataSchemaName = "Microsoft.Dynamics.CRM" // nolint:gochecknoglobals
+
+	ErrMissingSchema      = fmt.Errorf("missing schema %v in response", CRMMetadataSchemaName)
+	ErrMetadataProcessing = errors.New("metadata couldn't be processed")
+	ErrObjectNotFound     = errors.New("object not found")
+)
+
+// Please note: MSDynamics API does not return proper display names for objects and fields,
+// so the ListObjectMetadataResult will have display names that look like "accountleads".
+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
+	}
+
+	rsp, err := c.getXML(ctx, c.getURL("$metadata"))
+	if err != nil {
+		return nil, err
+	}
+
+	root, err := rsp.GetRoot()
+	if err != nil {
+		return nil, err
+	}
+
+	entities, err := extractEntities(root)
+	if err != nil {
+		return nil, err
+	}
+
+	result, err := convertEntitySetToMetadataSet(objectNames, entities)
+	if err != nil {
+		return nil, err
+	}
+
+	return &common.ListObjectMetadataResult{
+		Result: result,
+		Errors: nil,
+	}, nil
+}
+
+// collects field properties and groups them in entities, other data in XML is ignored.
+func extractEntities(root *xmldom.Node) (EntitySet, error) {
+	querySchema := fmt.Sprintf("/DataServices/Schema[@Namespace='%v']", CRMMetadataSchemaName)
+
+	schema := root.QueryOne(querySchema)
+	if schema == nil {
+		return nil, ErrMissingSchema
+	}
+
+	entities := NewEntitySet()
+	// List all field properties that exist for current schema
+	queryListAllSchemaProperties := fmt.Sprintf(
+		"/DataServices/Schema[@Namespace='%v']/EntityType[*]/Property/@Name", CRMMetadataSchemaName)
+	root.QueryEach(queryListAllSchemaProperties, func(index int, property *xmldom.Node) {
+		// parent of a property is an Entity.
+		// Entity may inherit properties from a parent
+		// We save entity name and the name of its parent, so later we can infer all properties by denormalisation
+		entityName := property.Parent.GetAttributeValue("Name")
+		parentName := property.Parent.GetAttributeValue("BaseType")
+		entity := entities.GetOrCreate(entityName, parentName)
+		propertyName := property.GetAttributeValue("Name")
+		entity.AddProperty(propertyName)
+	})
+
+	queryListAbstractEntities := fmt.Sprintf(
+		"/DataServices/Schema[@Namespace='%v']/EntityType[@Abstract='true']", CRMMetadataSchemaName)
+	root.QueryEach(queryListAbstractEntities, func(index int, abstractEntity *xmldom.Node) {
+		if len(abstractEntity.Children) == 0 {
+			// these entities were not included by previous query as they have no properties
+			// we programmatically find these special types, which are "primary values" but for structs
+			// Ex: crmbaseentity, crmmodelbaseentity,
+			entityName := abstractEntity.GetAttributeValue("Name")
+			// effectively only create
+			_ = entities.GetOrCreate(entityName, "")
+		}
+	})
+
+	// link every child with parent completing hierarchy
+	schemaAlias := schema.GetAttributeValue("Alias")
+	if err := entities.MatchParentsWithChildren(schemaAlias); err != nil {
+		return nil, errors.Join(ErrMetadataProcessing, err)
+	}
+
+	return entities, nil
+}
+
+// Select entities that match entity names of interest.
+// Every property has display identical to itself.
+func convertEntitySetToMetadataSet(names []string, entities EntitySet) (map[string]common.ObjectMetadata, error) {
+	result := map[string]common.ObjectMetadata{}
+
+	for _, name := range names {
+		entity, ok := entities[name]
+		if !ok {
+			return nil, fmt.Errorf("unknown entity %v %w", name, ErrObjectNotFound)
+		}
+
+		properties := entity.GetAllProperties()
+		fieldsMap := make(map[string]string)
+
+		for _, p := range properties {
+			fieldsMap[p] = p
+		}
+
+		result[name] = common.ObjectMetadata{
+			DisplayName: name,
+			FieldsMap:   fieldsMap,
+		}
+	}
+
+	return result, nil
+}
diff --git a/microsoftdynamicscrm/methods.go b/microsoftdynamicscrm/methods.go
new file mode 100644
index 00000000..53c09376
--- /dev/null
+++ b/microsoftdynamicscrm/methods.go
@@ -0,0 +1,81 @@
+package microsoftdynamicscrm
+
+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) patch(ctx context.Context, url string, body any) (*common.JSONHTTPResponse, error) {
+	rsp, err := c.Client.Patch(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 (c *Connector) getXML(ctx context.Context,
+	url string, headers ...common.Header,
+) (*common.XMLHTTPResponse, error) {
+	rsp, err := c.XMLClient.Get(ctx, url, headers...)
+	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/microsoftdynamicscrm/models.go b/microsoftdynamicscrm/models.go
new file mode 100644
index 00000000..1b1a279f
--- /dev/null
+++ b/microsoftdynamicscrm/models.go
@@ -0,0 +1,92 @@
+package microsoftdynamicscrm
+
+import (
+	"errors"
+	"strings"
+)
+
+var ErrInvalidParentRef = errors.New("referenced parent type doesn't exist for schema")
+
+// EntitySet organised by Entity.Name.
+type EntitySet map[string]*Entity
+
+func NewEntitySet() EntitySet {
+	return make(map[string]*Entity)
+}
+
+func (s EntitySet) GetNames() []string {
+	names := make([]string, len(s))
+	i := 0
+
+	for name := range s {
+		names[i] = name
+		i++
+	}
+
+	return names
+}
+
+func (s EntitySet) GetOrCreate(name, parentName string) *Entity {
+	if _, ok := s[name]; !ok {
+		s[name] = &Entity{
+			Name:       name,
+			properties: make([]string, 0),
+			parentName: parentName,
+		}
+	}
+
+	return s[name]
+}
+
+// Entity represents schema of the data returned by Microsoft
+// fields are inherited from parents so there is a tree hierarchy that can be traversed.
+type Entity struct {
+	Name       string
+	properties []string
+	parentName string
+	parent     *Entity
+}
+
+func (e *Entity) AddProperty(property string) {
+	e.properties = append(e.properties, property)
+}
+
+// GetRawParentName parents that are defined under schema are prefixed with its alias
+// this strips the prefix.
+func (e *Entity) GetRawParentName(schemaAlias string) string {
+	// strip prefix if we are using local schema
+	name, _ := strings.CutPrefix(e.parentName, schemaAlias+".")
+
+	return name
+}
+
+// MatchParentsWithChildren this will link a parent entity for everybody
+// only top root entities will have parent null.
+func (s EntitySet) MatchParentsWithChildren(schemaAlias string) error {
+	for _, entity := range s {
+		name := entity.GetRawParentName(schemaAlias)
+		// if it has parent then match
+		if len(name) != 0 {
+			parent, ok := s[name]
+			if !ok {
+				return ErrInvalidParentRef
+			}
+
+			entity.parent = parent
+		}
+	}
+
+	return nil
+}
+
+// GetAllProperties recursive function that includes inherited fields from parents.
+func (e *Entity) GetAllProperties() []string {
+	if e.parent == nil {
+		// this is root
+		return e.properties
+	}
+
+	parentProperties := e.parent.GetAllProperties()
+
+	return append(e.properties, parentProperties...)
+}
diff --git a/microsoftdynamicscrm/params.go b/microsoftdynamicscrm/params.go
new file mode 100644
index 00000000..a0bf7ea7
--- /dev/null
+++ b/microsoftdynamicscrm/params.go
@@ -0,0 +1,69 @@
+package microsoftdynamicscrm
+
+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 MS CRM supports auth client, workspace, etc. by delegation.
+type parameters struct {
+	paramsbuilder.Client
+	paramsbuilder.Workspace
+	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.Workspace.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 WithWorkspace(workspaceRef string) Option {
+	return func(params *parameters) {
+		params.WithWorkspace(workspaceRef)
+	}
+}
+
+func WithModule(module paramsbuilder.APIModule) Option {
+	return func(params *parameters) {
+		params.WithModule(module)
+	}
+}
diff --git a/microsoftdynamicscrm/parse.go b/microsoftdynamicscrm/parse.go
new file mode 100644
index 00000000..0d90d848
--- /dev/null
+++ b/microsoftdynamicscrm/parse.go
@@ -0,0 +1,36 @@
+package microsoftdynamicscrm
+
+import (
+	"github.com/amp-labs/connectors/common"
+	"github.com/spyzhov/ajson"
+)
+
+func getTotalSize(node *ajson.Node) (int64, error) {
+	return common.JSONManager.ArrSize(node, "value")
+}
+
+func getRecords(node *ajson.Node) ([]map[string]any, error) {
+	arr, err := common.JSONManager.GetArr(node, "value")
+	if err != nil {
+		return nil, err
+	}
+
+	return common.JSONManager.ArrToMap(arr)
+}
+
+func getNextRecordsURL(node *ajson.Node) (string, error) {
+	return common.JSONManager.GetString(node, "@odata.nextLink", true)
+}
+
+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/microsoftdynamicscrm/read.go b/microsoftdynamicscrm/read.go
new file mode 100644
index 00000000..7d989533
--- /dev/null
+++ b/microsoftdynamicscrm/read.go
@@ -0,0 +1,79 @@
+package microsoftdynamicscrm
+
+import (
+	"context"
+	"fmt"
+	"net/url"
+	"strings"
+
+	"github.com/amp-labs/connectors/common"
+)
+
+// 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()
+	}
+
+	// always include annotations header
+	// response will describe enums, foreign relationship, etc.
+	rsp, err := c.get(ctx, fullURL, newPaginationHeader(DefaultPageSize), common.Header{
+		Key:   "Prefer",
+		Value: `odata.include-annotations="*"`,
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	return common.ParseResult(
+		rsp,
+		getTotalSize,
+		getRecords,
+		getNextRecordsURL,
+		getMarshaledData,
+		config.Fields,
+	)
+}
+
+func newPaginationHeader(pageSize int) common.Header {
+	return common.Header{
+		Key:   "Prefer",
+		Value: fmt.Sprintf("odata.maxpagesize=%v", pageSize),
+	}
+}
+
+func makeQueryValues(config common.ReadParams) string {
+	queryValues := url.Values{}
+
+	if len(config.Fields) != 0 {
+		queryValues.Add("$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 "?" + result
+	}
+
+	return result
+}
diff --git a/microsoftdynamicscrm/write.go b/microsoftdynamicscrm/write.go
new file mode 100644
index 00000000..9d4723c4
--- /dev/null
+++ b/microsoftdynamicscrm/write.go
@@ -0,0 +1,43 @@
+package microsoftdynamicscrm
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/amp-labs/connectors/common"
+)
+
+type writeMethod func(context.Context, string, any) (*common.JSONHTTPResponse, error)
+
+// Write data will be used to Create or Update entity.
+// Return: common.WriteResult, where only the Success flag will be set.
+func (c *Connector) Write(ctx context.Context, config common.WriteParams) (*common.WriteResult, error) {
+	if len(config.ObjectName) == 0 {
+		return nil, common.ErrMissingObjects
+	}
+
+	url := c.getURL(config.ObjectName)
+
+	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 patch is supported for updating 'Single' resource
+		write = c.patch
+		// resource id is passed via brackets in OData spec
+		url = fmt.Sprintf("%s(%s)", url, config.RecordId)
+	}
+
+	// Neither Post nor Patch return any response data on successful completion
+	// Both complete with 204 NoContent
+	_, err := write(ctx, url, config.RecordData)
+	if err != nil {
+		return nil, err
+	}
+
+	return &common.WriteResult{
+		Success: true,
+	}, nil
+}
diff --git a/mock/connector.go b/mock/connector.go
new file mode 100644
index 00000000..0447bd98
--- /dev/null
+++ b/mock/connector.go
@@ -0,0 +1,93 @@
+package mock
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/providers"
+)
+
+type Connector struct {
+	client *common.JSONHTTPClient
+
+	read               func(ctx context.Context, params common.ReadParams) (*common.ReadResult, error)
+	write              func(ctx context.Context, params common.WriteParams) (*common.WriteResult, error)
+	listObjectMetadata func(ctx context.Context, objectNames []string) (*common.ListObjectMetadataResult, error)
+}
+
+func NewConnector(opts ...Option) (conn *Connector, outErr error) {
+	defer func() {
+		if re := recover(); re != nil {
+			tmp, ok := re.(error)
+			if !ok {
+				panic(re)
+			}
+
+			outErr = tmp
+			conn = nil
+		}
+	}()
+
+	params := &mockParams{
+		read: func(ctx context.Context, params common.ReadParams) (*common.ReadResult, error) {
+			return nil, fmt.Errorf("%w: %s", ErrNotImplemented, "read")
+		},
+		write: func(ctx context.Context, params common.WriteParams) (*common.WriteResult, error) {
+			return nil, fmt.Errorf("%w: %s", ErrNotImplemented, "write")
+		},
+		listObjectMetadata: func(ctx context.Context, objectNames []string) (*common.ListObjectMetadataResult, error) {
+			return nil, fmt.Errorf("%w: %s", ErrNotImplemented, "listObjectMetadata")
+		},
+	}
+	for _, opt := range opts {
+		opt(params)
+	}
+
+	params, err := params.prepare()
+	if err != nil {
+		return nil, err
+	}
+
+	return &Connector{
+		client:             params.client,
+		read:               params.read,
+		write:              params.write,
+		listObjectMetadata: params.listObjectMetadata,
+	}, nil
+}
+
+func (c *Connector) String() string {
+	return "mock"
+}
+
+func (c *Connector) Close() error {
+	return nil
+}
+
+func (c *Connector) JSONHTTPClient() *common.JSONHTTPClient {
+	return c.client
+}
+
+func (c *Connector) HTTPClient() *common.HTTPClient {
+	return c.client.HTTPClient
+}
+
+func (c *Connector) Provider() providers.Provider {
+	return providers.Mock
+}
+
+func (c *Connector) Read(ctx context.Context, params common.ReadParams) (*common.ReadResult, error) {
+	return c.read(ctx, params)
+}
+
+func (c *Connector) Write(ctx context.Context, params common.WriteParams) (*common.WriteResult, error) {
+	return c.write(ctx, params)
+}
+
+func (c *Connector) ListObjectMetadata(
+	ctx context.Context,
+	objectNames []string,
+) (*common.ListObjectMetadataResult, error) {
+	return c.listObjectMetadata(ctx, objectNames)
+}
diff --git a/mock/errors.go b/mock/errors.go
new file mode 100644
index 00000000..871876d2
--- /dev/null
+++ b/mock/errors.go
@@ -0,0 +1,10 @@
+package mock
+
+import (
+	"errors"
+)
+
+var (
+	ErrNotImplemented = errors.New("not implemented")
+	ErrMissingParam   = errors.New("missing required parameter")
+)
diff --git a/mock/params.go b/mock/params.go
new file mode 100644
index 00000000..2c1c4013
--- /dev/null
+++ b/mock/params.go
@@ -0,0 +1,83 @@
+package mock
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+
+	"github.com/amp-labs/connectors/common"
+)
+
+// Option is a function which mutates the hubspot connector configuration.
+type Option func(params *mockParams)
+
+// WithClient sets the http client to use for the connector. Saves some boilerplate.
+func WithClient(client *http.Client) Option {
+	return func(params *mockParams) {
+		WithAuthenticatedClient(client)(params)
+	}
+}
+
+// WithAuthenticatedClient sets the http client to use for the connector. Its usage is optional.
+func WithAuthenticatedClient(client common.AuthenticatedHTTPClient) Option {
+	return func(params *mockParams) {
+		params.client = &common.JSONHTTPClient{
+			HTTPClient: &common.HTTPClient{
+				Client:       client,
+				ErrorHandler: common.InterpretError,
+			},
+		}
+	}
+}
+
+// WithRead sets the read function for the connector.
+func WithRead(read func(ctx context.Context, params common.ReadParams) (*common.ReadResult, error)) Option {
+	return func(params *mockParams) {
+		params.read = read
+	}
+}
+
+// WithWrite sets the write function for the connector.
+func WithWrite(write func(ctx context.Context, params common.WriteParams) (*common.WriteResult, error)) Option {
+	return func(params *mockParams) {
+		params.write = write
+	}
+}
+
+// WithListObjectMetadata sets the listObjectMetadata function for the connector.
+func WithListObjectMetadata(
+	listObjectMetadata func(ctx context.Context, objectNames []string) (*common.ListObjectMetadataResult, error),
+) Option {
+	return func(params *mockParams) {
+		params.listObjectMetadata = listObjectMetadata
+	}
+}
+
+// mockParams is the internal configuration for the mock connector.
+type mockParams struct {
+	client             *common.JSONHTTPClient // required
+	read               func(ctx context.Context, params common.ReadParams) (*common.ReadResult, error)
+	write              func(ctx context.Context, params common.WriteParams) (*common.WriteResult, error)
+	listObjectMetadata func(ctx context.Context, objectNames []string) (*common.ListObjectMetadataResult, error)
+}
+
+// prepare finalizes and validates the connector configuration, and returns an error if it's invalid.
+func (p *mockParams) prepare() (out *mockParams, err error) {
+	if p.client == nil {
+		return nil, fmt.Errorf("%w: %s", ErrMissingParam, "client")
+	}
+
+	if p.read == nil {
+		return nil, fmt.Errorf("%w: %s", ErrMissingParam, "read")
+	}
+
+	if p.write == nil {
+		return nil, fmt.Errorf("%w: %s", ErrMissingParam, "write")
+	}
+
+	if p.listObjectMetadata == nil {
+		return nil, fmt.Errorf("%w: %s", ErrMissingParam, "listObjectMetadata")
+	}
+
+	return p, nil
+}
diff --git a/outreach/README.md b/outreach/README.md
new file mode 100644
index 00000000..22145e31
--- /dev/null
+++ b/outreach/README.md
@@ -0,0 +1,24 @@
+# Outreach connector
+
+
+## Read
+Read is used to list all records of a given type. For example, if you want to list all users, you would use the `Read` method with the `users` object.
+
+### Example Usage
+
+```
+
+// Create the outreach connector instance 
+// This assumes you called the instance client
+
+// 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)
+}
+
+```
+
+
diff --git a/outreach/client.go b/outreach/client.go
new file mode 100644
index 00000000..05eb8122
--- /dev/null
+++ b/outreach/client.go
@@ -0,0 +1,16 @@
+package outreach
+
+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/outreach/connector.go b/outreach/connector.go
new file mode 100644
index 00000000..43ec3901
--- /dev/null
+++ b/outreach/connector.go
@@ -0,0 +1,61 @@
+package outreach
+
+import (
+	"fmt"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/providers"
+)
+
+const (
+	providerOptionRestApiURL = "restAPIURL"
+)
+
+type Connector struct {
+	BaseURL string
+	Client  *common.JSONHTTPClient
+}
+
+func NewConnector(opts ...Option) (conn *Connector, outErr error) {
+	defer func() {
+		if re := recover(); re != nil {
+			tmp, ok := re.(error)
+			if !ok {
+				panic(re)
+			}
+
+			outErr = tmp
+			conn = nil
+		}
+	}()
+
+	params := &outreachParams{}
+	for _, opt := range opts {
+		opt(params)
+	}
+
+	var err error
+
+	params, err = params.prepare()
+	if err != nil {
+		return nil, err
+	}
+
+	// Read provider info
+	providerInfo, err := providers.ReadInfo(providers.Outreach, nil)
+	if err != nil {
+		return nil, err
+	}
+
+	restApi, ok := providerInfo.GetOption(providerOptionRestApiURL)
+	if !ok {
+		return nil, fmt.Errorf("restAPIURL not set: %w", providers.ErrProviderOptionNotFound)
+	}
+
+	params.client.HTTPClient.Base = providerInfo.BaseURL
+
+	return &Connector{
+		Client:  params.client,
+		BaseURL: restApi,
+	}, nil
+}
diff --git a/outreach/errors.go b/outreach/errors.go
new file mode 100644
index 00000000..3ac48c17
--- /dev/null
+++ b/outreach/errors.go
@@ -0,0 +1,16 @@
+package outreach
+
+import (
+	"errors"
+)
+
+var (
+	ErrMissingClient = errors.New("JSON http client not set")
+	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")
+)
+
+func (c *Connector) HandleError(err error) error {
+	return err
+}
diff --git a/outreach/methods.go b/outreach/methods.go
new file mode 100644
index 00000000..f59d4b92
--- /dev/null
+++ b/outreach/methods.go
@@ -0,0 +1,16 @@
+package outreach
+
+import (
+	"context"
+
+	"github.com/amp-labs/connectors/common"
+)
+
+func (c *Connector) get(ctx context.Context, url string) (*common.JSONHTTPResponse, error) {
+	res, err := c.Client.Get(ctx, url)
+	if err != nil {
+		return nil, c.HandleError(err)
+	}
+
+	return res, nil
+}
diff --git a/outreach/params.go b/outreach/params.go
new file mode 100644
index 00000000..00a585fa
--- /dev/null
+++ b/outreach/params.go
@@ -0,0 +1,50 @@
+package outreach
+
+import (
+	"context"
+	"net/http"
+
+	"github.com/amp-labs/connectors/common"
+	"golang.org/x/oauth2"
+)
+
+type outreachParams struct {
+	client *common.JSONHTTPClient
+}
+
+type Option func(params *outreachParams)
+
+func WithClient(ctx context.Context, client *http.Client, config *oauth2.Config, token *oauth2.Token,
+) Option {
+	return func(params *outreachParams) {
+		oauthclient, err := common.NewOAuthHTTPClient(
+			ctx, common.WithClient(client),
+			common.WithOAuthConfig(config),
+			common.WithOAuthToken(token),
+		)
+		if err != nil {
+			panic(err)
+		}
+
+		WithAuthenticatedClient(oauthclient)(params)
+	}
+}
+
+func WithAuthenticatedClient(client common.AuthenticatedHTTPClient) Option {
+	return func(params *outreachParams) {
+		params.client = &common.JSONHTTPClient{
+			HTTPClient: &common.HTTPClient{
+				Client:       client,
+				ErrorHandler: common.InterpretError,
+			},
+		}
+	}
+}
+
+func (params *outreachParams) prepare() (*outreachParams, error) {
+	if params.client == nil {
+		return nil, ErrMissingClient
+	}
+
+	return params, nil
+}
diff --git a/outreach/parse.go b/outreach/parse.go
new file mode 100644
index 00000000..33fd1e84
--- /dev/null
+++ b/outreach/parse.go
@@ -0,0 +1,122 @@
+package outreach
+
+import (
+	"github.com/amp-labs/connectors/common"
+	"github.com/spyzhov/ajson"
+)
+
+// getNextRecords returns the "next" url for the next page of results,
+// If available, else returns an empty string.
+func getNextRecordsURL(node *ajson.Node) (string, error) {
+	var nextPage string
+
+	if node.HasKey("links") {
+		links, err := parsePagingNext(node)
+		if err != nil {
+			return "", err
+		}
+
+		nextPage, err = checkURL(links)
+		if err != nil {
+			return "", err
+		}
+	}
+
+	return nextPage, nil
+}
+
+// parsePagingNext is a helper to return the links node.
+func parsePagingNext(node *ajson.Node) (*ajson.Node, error) {
+	links, err := node.GetKey("links")
+	if err != nil {
+		return nil, err
+	}
+
+	if !links.IsObject() {
+		return nil, ErrNotObject
+	}
+
+	return links, nil
+}
+
+// checkURL is a helper function that returns the url of the  next page.
+func checkURL(node *ajson.Node) (string, error) {
+	if !node.HasKey("next") {
+		return "", nil
+	}
+
+	next, err := node.GetKey("next")
+	if err != nil {
+		return "", err
+	}
+
+	if !next.IsString() {
+		return "", ErrNotString
+	}
+
+	return next.String(), nil
+}
+
+// getRecords returns the records from the response.
+func getRecords(node *ajson.Node) ([]map[string]interface{}, error) {
+	records, err := node.GetKey("data")
+	if err != nil {
+		return nil, err
+	}
+
+	if !records.IsArray() {
+		return nil, ErrNotArray
+	}
+
+	arr := records.MustArray()
+
+	out := make([]map[string]interface{}, 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
+		}
+
+		out = append(out, m)
+	}
+
+	return out, nil
+}
+
+// getTotalSize returns the total number of records that match the query.
+func getTotalSize(node *ajson.Node) (int64, error) {
+	node, err := node.GetKey("data")
+	if err != nil {
+		return 0, err
+	}
+
+	if !node.IsArray() {
+		return 0, ErrNotArray
+	}
+
+	return int64(node.Size()), nil
+}
+
+// getMarshaledData accepts a list of records and returns a list of structured data ([]ReadResultRow).
+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/outreach/provider.go b/outreach/provider.go
new file mode 100644
index 00000000..fcf53bd7
--- /dev/null
+++ b/outreach/provider.go
@@ -0,0 +1,8 @@
+package outreach
+
+import "github.com/amp-labs/connectors/providers"
+
+// Provider returns the connector provider.
+func (c *Connector) Provider() providers.Provider {
+	return providers.Outreach
+}
diff --git a/outreach/read.go b/outreach/read.go
new file mode 100644
index 00000000..01a3d6bd
--- /dev/null
+++ b/outreach/read.go
@@ -0,0 +1,42 @@
+package outreach
+
+import (
+	"context"
+	"net/url"
+
+	"github.com/amp-labs/connectors/common"
+)
+
+func (c *Connector) Read(ctx context.Context, config common.ReadParams) (*common.ReadResult, error) {
+	var (
+		res    *common.JSONHTTPResponse
+		err    error
+		fields []string
+	)
+
+	if len(config.NextPage) > 0 {
+		// If NextPage is set, then we're reading the next page of results.
+		// The NextPage URL has all the necessary parameters.
+		res, err = c.get(ctx, config.NextPage.String())
+		if err != nil {
+			return nil, err
+		}
+	} else {
+		fullURL, err := url.JoinPath(c.BaseURL, config.ObjectName)
+		if err != nil {
+			return nil, err
+		}
+
+		res, err = c.get(ctx, fullURL)
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	return common.ParseResult(res, getTotalSize,
+		getRecords,
+		getNextRecordsURL,
+		getMarshaledData,
+		fields,
+	)
+}
diff --git a/basic/string.go b/outreach/string.go
similarity index 81%
rename from basic/string.go
rename to outreach/string.go
index e38aad4e..dfd4cd51 100644
--- a/basic/string.go
+++ b/outreach/string.go
@@ -1,4 +1,4 @@
-package basic
+package outreach
 
 func (c *Connector) String() string {
 	return c.Provider() + ".Connector"
diff --git a/providers/catalog.go b/providers/catalog.go
index 819ef715..248cbfa6 100644
--- a/providers/catalog.go
+++ b/providers/catalog.go
@@ -5,14 +5,18 @@ 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"
@@ -24,21 +28,37 @@ const (
 	Zoom                                Provider = "zoom"
 	Intercom                            Provider = "intercom"
 	Capsule                             Provider = "capsule"
-	DocuSign                            Provider = "docuSign"
-	DocuSignDeveloper                   Provider = "docuSignDeveloper"
+	Wrike                               Provider = "wrike"
+	Docusign                            Provider = "docusign"
+	DocusignDeveloper                   Provider = "docusignDeveloper"
 	Calendly                            Provider = "calendly"
 	AWeber                              Provider = "aWeber"
 	GetResponse                         Provider = "getResponse"
 	ConstantContact                     Provider = "constantContact"
-	MicrosoftDynamics365Sales           Provider = "microsoftDynamics365Sales"
+	MicrosoftDynamics365CRM             Provider = "microsoftDynamics365CRM"
 	MicrosoftDynamics365BusinessCentral Provider = "microsoftDynamics365BusinessCentral"
 	Gainsight                           Provider = "gainsight"
 	Box                                 Provider = "box"
-	GoogleCalendar                      Provider = "googleCalendar"
 	ZendeskSupport                      Provider = "zendeskSupport"
 	ZendeskChat                         Provider = "zendeskChat"
 	WordPress                           Provider = "wordPress"
 	Airtable                            Provider = "airtable"
+	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"
+	Typeform                            Provider = "typeform"
+	Zuora                               Provider = "zuora"
+	DropboxSign                         Provider = "dropboxSign"
 )
 
 // ================================================================================
@@ -67,7 +87,7 @@ var catalog = CatalogType{ // nolint:gochecknoglobals
 				Insert: false,
 				Update: false,
 				Upsert: true,
-				Delete: false,
+				Delete: true,
 			},
 			Proxy:     true,
 			Read:      true,
@@ -142,6 +162,9 @@ var catalog = CatalogType{ // nolint:gochecknoglobals
 			TokenURL:                  "https://accounts.salesloft.com/oauth/token",
 			ExplicitScopesRequired:    false,
 			ExplicitWorkspaceRequired: false,
+			TokenMetadataFields: TokenMetadataFields{
+				ScopesField: "scope",
+			},
 		},
 		Support: Support{
 			BulkWrite: BulkWriteSupport{
@@ -166,6 +189,42 @@ var catalog = CatalogType{ // nolint:gochecknoglobals
 			TokenURL:                  "https://api.outreach.io/oauth/token",
 			ExplicitScopesRequired:    true,
 			ExplicitWorkspaceRequired: false,
+			TokenMetadataFields: TokenMetadataFields{
+				ScopesField: "scope",
+			},
+		},
+		Support: Support{
+			BulkWrite: BulkWriteSupport{
+				Insert: false,
+				Update: false,
+				Upsert: false,
+				Delete: false,
+			},
+			Proxy:     true,
+			Read:      false,
+			Subscribe: false,
+			Write:     false,
+		},
+		ProviderOpts: ProviderOpts{
+			"restAPIURL": "https://api.outreach.io/api/v2",
+		},
+	},
+
+	// RingCentral configuration
+
+	RingCentral: {
+		AuthType: Oauth2,
+		BaseURL:  "https://platform.ringcentral.com",
+		OauthOpts: OauthOpts{
+			GrantType:                 PKCE,
+			AuthURL:                   "https://platform.ringcentral.com/restapi/oauth/authorize",
+			TokenURL:                  "https://platform.ringcentral.com/restapi/oauth/token",
+			ExplicitScopesRequired:    false,
+			ExplicitWorkspaceRequired: false,
+			TokenMetadataFields: TokenMetadataFields{
+				ScopesField:      "scope",
+				ConsumerRefField: "owner_id",
+			},
 		},
 		Support: Support{
 			BulkWrite: BulkWriteSupport{
@@ -229,6 +288,33 @@ var catalog = CatalogType{ // nolint:gochecknoglobals
 		},
 	},
 
+	// Wrikle configuration
+	Wrike: {
+		AuthType: Oauth2,
+		BaseURL:  "https://www.wrike.com/api",
+		OauthOpts: OauthOpts{
+			AuthURL:                   "https://www.wrike.com/oauth2/authorize",
+			TokenURL:                  "https://www.wrike.com/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,
+		},
+	},
+
 	// Copper configuration
 	Copper: {
 		AuthType: Oauth2,
@@ -277,11 +363,67 @@ var catalog = CatalogType{ // nolint:gochecknoglobals
 		},
 	},
 
+	// Mural configuration
+	Mural: {
+		AuthType: Oauth2,
+		BaseURL:  "https://api.mural.co/api",
+		OauthOpts: OauthOpts{
+			AuthURL:                   "https://api.mural.co/oauth/authorize",
+			TokenURL:                  "https://api.mural.co/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,
+		},
+	},
+
+	// Klaviyo configuration
+	Klaviyo: {
+		AuthType: Oauth2,
+		BaseURL:  "https://a.klaviyo.com",
+		OauthOpts: OauthOpts{
+			GrantType:                 PKCE,
+			AuthURL:                   "https://www.klaviyo.com/oauth/authorize",
+			TokenURL:                  "https://a.klaviyo.com/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,
+		},
+	},
+
 	// Sellsy configuration
 	Sellsy: {
 		AuthType: Oauth2,
 		BaseURL:  "https://api.sellsy.com",
 		OauthOpts: OauthOpts{
+			GrantType:                 PKCE,
 			AuthURL:                   "https://login.sellsy.com/oauth2/authorization",
 			TokenURL:                  "https://login.sellsy.com/oauth2/access-tokens",
 			ExplicitScopesRequired:    false,
@@ -382,6 +524,9 @@ var catalog = CatalogType{ // nolint:gochecknoglobals
 			TokenURL:                  "https://app.asana.com/-/oauth_token",
 			ExplicitScopesRequired:    false,
 			ExplicitWorkspaceRequired: false,
+			TokenMetadataFields: TokenMetadataFields{
+				ConsumerRefField: "data.id",
+			},
 		},
 		Support: Support{
 			BulkWrite: BulkWriteSupport{
@@ -400,7 +545,7 @@ var catalog = CatalogType{ // nolint:gochecknoglobals
 	// Dropbox configuration
 	Dropbox: {
 		AuthType: Oauth2,
-		BaseURL:  "https://api.dropboxapi.com/2/",
+		BaseURL:  "https://api.dropboxapi.com",
 		OauthOpts: OauthOpts{
 			AuthURL:                   "https://www.dropbox.com/oauth2/authorize",
 			TokenURL:                  "https://api.dropboxapi.com/oauth2/token",
@@ -452,12 +597,16 @@ var catalog = CatalogType{ // nolint:gochecknoglobals
 	// Gong configuration
 	Gong: {
 		AuthType: Oauth2,
-		BaseURL:  "https://{{.workspace}}.api.gong.io",
+		BaseURL:  "https://api.gong.io",
 		OauthOpts: OauthOpts{
 			AuthURL:                   "https://app.gong.io/oauth2/authorize",
 			TokenURL:                  "https://app.gong.io/oauth2/generate-customer-token",
 			ExplicitScopesRequired:    true,
 			ExplicitWorkspaceRequired: false,
+			TokenMetadataFields: TokenMetadataFields{
+				ScopesField:      "scope",
+				ConsumerRefField: "client_id",
+			},
 		},
 		Support: Support{
 			BulkWrite: BulkWriteSupport{
@@ -521,32 +670,34 @@ var catalog = CatalogType{ // nolint:gochecknoglobals
 		},
 	},
 
-	// DocuSign configuration
-	DocuSign: {
-		AuthType: Oauth2,
-		BaseURL:  "https://{{.workspace}}.docusign.net",
-		OauthOpts: OauthOpts{
-			AuthURL:                   "https://account.docusign.com/oauth/auth",
-			TokenURL:                  "https://account.docusign.com/oauth/token",
-			ExplicitScopesRequired:    true,
-			ExplicitWorkspaceRequired: true,
-		},
-		Support: Support{
-			BulkWrite: BulkWriteSupport{
-				Insert: false,
-				Update: false,
-				Upsert: false,
-				Delete: false,
-			},
-			Proxy:     false,
-			Read:      false,
-			Subscribe: false,
-			Write:     false,
-		},
-	},
+	// 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 Developer configuration
-	DocuSignDeveloper: {
+	// Docusign Developer configuration
+	DocusignDeveloper: {
 		AuthType: Oauth2,
 		BaseURL:  "https://demo.docusign.net",
 		OauthOpts: OauthOpts{
@@ -574,6 +725,7 @@ var catalog = CatalogType{ // nolint:gochecknoglobals
 		AuthType: Oauth2,
 		BaseURL:  "https://api.calendly.com",
 		OauthOpts: OauthOpts{
+			GrantType:                 AuthorizationCode,
 			AuthURL:                   "https://auth.calendly.com/oauth/authorize",
 			TokenURL:                  "https://auth.calendly.com/oauth/token",
 			ExplicitScopesRequired:    false,
@@ -641,11 +793,11 @@ var catalog = CatalogType{ // nolint:gochecknoglobals
 		},
 	},
 
-	// MS Sales configuration
-	MicrosoftDynamics365Sales: {
+	MicrosoftDynamics365CRM: {
 		AuthType: Oauth2,
 		BaseURL:  "https://{{.workspace}}.api.crm.dynamics.com",
 		OauthOpts: OauthOpts{
+			GrantType:                 AuthorizationCode,
 			AuthURL:                   "https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
 			TokenURL:                  "https://login.microsoftonline.com/common/oauth2/v2.0/token",
 			ExplicitScopesRequired:    true,
@@ -745,6 +897,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,
@@ -764,12 +917,87 @@ var catalog = CatalogType{ // nolint:gochecknoglobals
 		},
 	},
 
-	GoogleCalendar: {
+	// Zendesk Support configuration
+	ZendeskSupport: {
+		AuthType: Oauth2,
+		BaseURL:  "https://{{.workspace}}.zendesk.com",
+		OauthOpts: OauthOpts{
+			GrantType:                 AuthorizationCode,
+			AuthURL:                   "https://{{.workspace}}.zendesk.com/oauth/authorizations/new",
+			TokenURL:                  "https://{{.workspace}}.zendesk.com/oauth/tokens",
+			ExplicitScopesRequired:    true,
+			ExplicitWorkspaceRequired: true,
+		},
+		Support: Support{
+			BulkWrite: BulkWriteSupport{
+				Insert: false,
+				Update: false,
+				Upsert: false,
+				Delete: false,
+			},
+			Proxy:     true,
+			Read:      false,
+			Subscribe: false,
+			Write:     false,
+		},
+	},
+
+	ZendeskChat: {
+		AuthType: Oauth2,
+		BaseURL:  "https://www.zopim.com",
+		OauthOpts: OauthOpts{
+			GrantType:                 AuthorizationCode,
+			AuthURL:                   "https://www.zopim.com/oauth2/authorizations/new?subdomain={{.workspace}}",
+			TokenURL:                  "https://www.zopim.com/oauth2/token",
+			ExplicitScopesRequired:    true,
+			ExplicitWorkspaceRequired: true,
+		},
+		Support: Support{
+			BulkWrite: BulkWriteSupport{
+				Insert: false,
+				Update: false,
+				Upsert: false,
+				Delete: false,
+			},
+			Proxy:     false,
+			Read:      false,
+			Subscribe: false,
+			Write:     false,
+		},
+	},
+
+	// WordPress Support configuration
+	WordPress: {
+		AuthType: Oauth2,
+		BaseURL:  "https://public-api.wordpress.com",
+		OauthOpts: OauthOpts{
+			AuthURL:                   "https://public-api.wordpress.com/oauth2/authorize",
+			TokenURL:                  "https://public-api.wordpress.com/oauth2/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,
+		},
+	},
+
+	// Airtable Support Configuration
+	Airtable: {
 		AuthType: Oauth2,
-		BaseURL:  "https://www.googleapis.com/calendar",
+		BaseURL:  "https://api.airtable.com",
 		OauthOpts: OauthOpts{
-			AuthURL:                   "https://accounts.google.com/o/oauth2/v2/auth",
-			TokenURL:                  "https://oauth2.googleapis.com/token",
+			GrantType:                 PKCE,
+			AuthURL:                   "https://airtable.com/oauth2/v1/authorize",
+			TokenURL:                  "https://airtable.com/oauth2/v1/token",
 			ExplicitScopesRequired:    true,
 			ExplicitWorkspaceRequired: false,
 			TokenMetadataFields: TokenMetadataFields{
@@ -790,15 +1018,44 @@ var catalog = CatalogType{ // nolint:gochecknoglobals
 		},
 	},
 
-	// Zendesk Support configuration
-	ZendeskSupport: {
+	// Slack configuration
+	Slack: {
 		AuthType: Oauth2,
-		BaseURL:  "https://{{.workspace}}.zendesk.com",
+		BaseURL:  "https://slack.com/api",
 		OauthOpts: OauthOpts{
-			AuthURL:                   "https://{{.workspace}}.zendesk.com/oauth/authorizations/new",
-			TokenURL:                  "https://{{.workspace}}.zendesk.com/oauth/tokens",
+			GrantType:                 AuthorizationCode,
+			AuthURL:                   "https://slack.com/oauth/v2/authorize",
+			TokenURL:                  "https://slack.com/api/oauth.v2.access",
 			ExplicitScopesRequired:    true,
 			ExplicitWorkspaceRequired: true,
+			TokenMetadataFields: TokenMetadataFields{
+				ScopesField:       "scope",
+				WorkspaceRefField: "workspace_name",
+			},
+		},
+		Support: Support{
+			BulkWrite: BulkWriteSupport{
+				Insert: false,
+				Update: false,
+				Upsert: false,
+				Delete: false,
+			},
+			Proxy:     false,
+			Read:      false,
+			Subscribe: false,
+			Write:     false,
+		},
+	},
+	// HelpScoutMailbox Support Configuration
+	HelpScoutMailbox: {
+		AuthType: Oauth2,
+		BaseURL:  "https://api.helpscout.net",
+		OauthOpts: OauthOpts{
+			GrantType:                 AuthorizationCode,
+			AuthURL:                   "https://secure.helpscout.net/authentication/authorizeClientApplication",
+			TokenURL:                  "https://api.helpscout.net/v2/oauth2/token",
+			ExplicitScopesRequired:    false,
+			ExplicitWorkspaceRequired: false,
 		},
 		Support: Support{
 			BulkWrite: BulkWriteSupport{
@@ -814,14 +1071,44 @@ var catalog = CatalogType{ // nolint:gochecknoglobals
 		},
 	},
 
-	ZendeskChat: {
+	// Timely Configuration
+	Timely: {
 		AuthType: Oauth2,
-		BaseURL:  "https://www.zopim.com",
+		BaseURL:  "https://api.timelyapp.com",
 		OauthOpts: OauthOpts{
-			AuthURL:                   "https://www.zopim.com/oauth2/authorizations/new?subdomain={{.workspace}}",
-			TokenURL:                  "https://www.zopim.com/oauth2/token",
+			GrantType:                 AuthorizationCode,
+			AuthURL:                   "https://api.timelyapp.com/1.1/oauth/authorize",
+			TokenURL:                  "https://api.timelyapp.com/1.1/oauth/token",
+			ExplicitScopesRequired:    false,
+			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,
+		},
+	},
+
+	// Atlassian configuration
+	Atlassian: {
+		AuthType: Oauth2,
+		BaseURL:  "https://api.atlassian.com",
+		OauthOpts: OauthOpts{
+			GrantType:                 AuthorizationCode,
+			AuthURL:                   "https://auth.atlassian.com/authorize",
+			TokenURL:                  "https://auth.atlassian.com/oauth/token",
 			ExplicitScopesRequired:    true,
-			ExplicitWorkspaceRequired: true,
+			ExplicitWorkspaceRequired: false,
 		},
 		Support: Support{
 			BulkWrite: BulkWriteSupport{
@@ -837,15 +1124,47 @@ var catalog = CatalogType{ // nolint:gochecknoglobals
 		},
 	},
 
-	// WordPress Support configuration
-	WordPress: {
+	// Webflow Support Configuration
+	Webflow: {
 		AuthType: Oauth2,
-		BaseURL:  "https://public-api.wordpress.com",
+		BaseURL:  "https://api.webflow.com",
 		OauthOpts: OauthOpts{
-			AuthURL:                   "https://public-api.wordpress.com/oauth2/authorize",
-			TokenURL:                  "https://public-api.wordpress.com/oauth2/token",
-			ExplicitScopesRequired:    false,
+			GrantType:                 AuthorizationCode,
+			AuthURL:                   "https://webflow.com/oauth/authorize",
+			TokenURL:                  "https://api.webflow.com/oauth/access_token",
+			ExplicitScopesRequired:    true,
 			ExplicitWorkspaceRequired: false,
+			TokenMetadataFields: TokenMetadataFields{
+				ScopesField: "scope",
+			},
+		},
+		Support: Support{
+			BulkWrite: BulkWriteSupport{
+				Insert: false,
+				Update: false,
+				Upsert: false,
+				Delete: false,
+			},
+			Proxy:     true,
+			Read:      false,
+			Subscribe: false,
+			Write:     false,
+		},
+	},
+
+	// Smartsheet Support Configuration
+	Smartsheet: {
+		AuthType: Oauth2,
+		BaseURL:  "https://api.smartsheet.com",
+		OauthOpts: OauthOpts{
+			GrantType:                 AuthorizationCode,
+			AuthURL:                   "https://app.smartsheet.com/b/authorize",
+			TokenURL:                  "https://api.smartsheet.com/2.0/token",
+			ExplicitScopesRequired:    true,
+			ExplicitWorkspaceRequired: false,
+			TokenMetadataFields: TokenMetadataFields{
+				ScopesField: "scope",
+			},
 		},
 		Support: Support{
 			BulkWrite: BulkWriteSupport{
@@ -861,14 +1180,98 @@ var catalog = CatalogType{ // nolint:gochecknoglobals
 		},
 	},
 
-	// Airtable Support Configuration
-	Airtable: {
+	// StackExchange configuration
+	StackExchange: {
 		AuthType: Oauth2,
-		BaseURL:  "https://api.airtable.com",
+		BaseURL:  "https://api.stackexchange.com",
 		OauthOpts: OauthOpts{
-			GrantType:                 PKCE,
-			AuthURL:                   "https://airtable.com/oauth2/v1/authorize",
-			TokenURL:                  "https://airtable.com/oauth2/v1/token",
+			GrantType:                 AuthorizationCode,
+			AuthURL:                   "https://stackoverflow.com/oauth",
+			TokenURL:                  "https://stackoverflow.com/oauth/access_token/json",
+			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,
+		},
+	},
+
+	// Google Support Configuration
+	Google: {
+		AuthType: Oauth2,
+		BaseURL:  "https://www.googleapis.com",
+		OauthOpts: OauthOpts{
+			GrantType:                 AuthorizationCode,
+			AuthURL:                   "https://accounts.google.com/o/oauth2/v2/auth",
+			TokenURL:                  "https://oauth2.googleapis.com/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,
+		},
+	},
+
+	// GoogleContacts Support Configuration
+	GoogleContacts: {
+		AuthType: Oauth2,
+		BaseURL:  "https://people.googleapis.com",
+		OauthOpts: OauthOpts{
+			GrantType:                 AuthorizationCode,
+			AuthURL:                   "https://accounts.google.com/o/oauth2/v2/auth",
+			TokenURL:                  "https://oauth2.googleapis.com/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,
+		},
+	},
+
+	// GoogleMail Support Configuration
+	GoogleMail: {
+		AuthType: Oauth2,
+		BaseURL:  "https://gmail.googleapis.com",
+		OauthOpts: OauthOpts{
+			GrantType:                 AuthorizationCode,
+			AuthURL:                   "https://accounts.google.com/o/oauth2/v2/auth",
+			TokenURL:                  "https://oauth2.googleapis.com/token",
 			ExplicitScopesRequired:    true,
 			ExplicitWorkspaceRequired: false,
 			TokenMetadataFields: TokenMetadataFields{
@@ -888,4 +1291,160 @@ var catalog = CatalogType{ // nolint:gochecknoglobals
 			Write:     false,
 		},
 	},
+
+	Monday: {
+		AuthType: Oauth2,
+		BaseURL:  "https://api.monday.com",
+		OauthOpts: OauthOpts{
+			GrantType:                 AuthorizationCode,
+			AuthURL:                   "https://auth.monday.com/oauth2/authorize",
+			TokenURL:                  "https://auth.monday.com/oauth2/token",
+			ExplicitScopesRequired:    false,
+			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,
+		},
+	},
+	// Figma Support Configuration
+	Figma: {
+		AuthType: Oauth2,
+		BaseURL:  "https://api.figma.com",
+		OauthOpts: OauthOpts{
+			GrantType:                 AuthorizationCode,
+			AuthURL:                   "https://www.figma.com/oauth",
+			TokenURL:                  "https://www.figma.com/api/oauth/token",
+			ExplicitScopesRequired:    true,
+			ExplicitWorkspaceRequired: false,
+			TokenMetadataFields: TokenMetadataFields{
+				ConsumerRefField: "user_id",
+			},
+		},
+		Support: Support{
+			BulkWrite: BulkWriteSupport{
+				Insert: false,
+				Update: false,
+				Upsert: false,
+				Delete: false,
+			},
+			Proxy:     false,
+			Read:      false,
+			Subscribe: false,
+			Write:     false,
+		},
+	},
+	// Miro Support Configuration
+	Miro: {
+		AuthType: Oauth2,
+		BaseURL:  "https://api.miro.com",
+		OauthOpts: OauthOpts{
+			GrantType:                 AuthorizationCode,
+			AuthURL:                   "https://miro.com/oauth/authorize",
+			TokenURL:                  "https://api.miro.com/v1/oauth/token",
+			ExplicitScopesRequired:    false,
+			ExplicitWorkspaceRequired: false,
+			TokenMetadataFields: TokenMetadataFields{
+				ConsumerRefField:  "user_id",
+				WorkspaceRefField: "team_id",
+				ScopesField:       "scope",
+			},
+		},
+		Support: Support{
+			BulkWrite: BulkWriteSupport{
+				Insert: false,
+				Update: false,
+				Upsert: false,
+				Delete: false,
+			},
+			Proxy:     true,
+			Read:      false,
+			Subscribe: false,
+			Write:     false,
+		},
+	},
+	Typeform: {
+		AuthType: Oauth2,
+		BaseURL:  "https://api.typeform.com",
+		OauthOpts: OauthOpts{
+			GrantType:                 AuthorizationCode,
+			AuthURL:                   "https://api.typeform.com/oauth/authorize",
+			TokenURL:                  "https://api.typeform.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,
+		},
+	},
+
+	// Zuora Configuration
+	Zuora: {
+		AuthType: Oauth2,
+		BaseURL:  "https://{{.subdomain}}.zuora.com",
+		OauthOpts: OauthOpts{
+			GrantType:                 ClientCredentials,
+			AuthURL:                   "https://{{.subdomain}}.zuora.com/oauth/auth_mock",
+			TokenURL:                  "https://{{.subdomain}}.zuora.com/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,
+		},
+	},
+
+	// DropboxSign Configuration
+	DropboxSign: {
+		AuthType: Oauth2,
+		BaseURL:  "https://api.hellosign.com",
+		OauthOpts: OauthOpts{
+			GrantType:                 AuthorizationCode,
+			AuthURL:                   "https://app.hellosign.com/oauth/authorize",
+			TokenURL:                  "https://app.hellosign.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,
+		},
+	},
 }
diff --git a/providers/types.gen.go b/providers/types.gen.go
index 2bebb1ce..bdbb7b07 100644
--- a/providers/types.gen.go
+++ b/providers/types.gen.go
@@ -5,6 +5,7 @@ package providers
 
 // Defines values for AuthType.
 const (
+	None   AuthType = "none"
 	Oauth2 AuthType = "oauth2"
 )
 
diff --git a/providers/types.yaml b/providers/types.yaml
index cfae4d96..b3fe8e31 100644
--- a/providers/types.yaml
+++ b/providers/types.yaml
@@ -16,7 +16,7 @@ components:
   schemas:
     AuthType:
       type: string
-      enum: [oauth2]
+      enum: [oauth2, none]
       x-oapi-codegen-extra-tags:
         validate: required
 
diff --git a/salesforce/bulk.go b/salesforce/bulk.go
index e2f4bfe2..2ec1eb18 100644
--- a/salesforce/bulk.go
+++ b/salesforce/bulk.go
@@ -14,9 +14,10 @@ import (
 )
 
 const (
-	Insert BulkWriteMode = "insert"
-	Upsert BulkWriteMode = "upsert"
-	Update BulkWriteMode = "update"
+	Insert BulkOperationMode = "insert"
+	Upsert BulkOperationMode = "upsert"
+	Update BulkOperationMode = "update"
+	Delete BulkOperationMode = "delete"
 
 	JobStateAborted        = "Aborted"
 	JobStateFailed         = "Failed"
@@ -50,10 +51,10 @@ type BulkOperationParams struct {
 	CSVData io.Reader // required
 
 	// Salesforce operation mode
-	Mode BulkWriteMode
+	Mode BulkOperationMode
 }
 
-type BulkWriteMode string
+type BulkOperationMode string
 
 // BulkOperation is what's returned from writing data via the BulkOperation call.
 type BulkOperationResult struct {
@@ -109,14 +110,7 @@ func (c *Connector) BulkWrite( //nolint:funlen,cyclop
 	config BulkOperationParams,
 ) (*BulkOperationResult, error) {
 	// Only support upsert for now
-	switch config.Mode {
-	case Upsert:
-		break
-	case Insert:
-		return nil, fmt.Errorf("%w: %s", ErrUnsupportedMode, "insert")
-	case Update:
-		return nil, fmt.Errorf("%w: %s", ErrUnsupportedMode, "Update")
-	default:
+	if config.Mode != Upsert {
 		return nil, fmt.Errorf("%w: %s", ErrUnsupportedMode, config.Mode)
 	}
 
@@ -128,12 +122,12 @@ func (c *Connector) BulkWrite( //nolint:funlen,cyclop
 		"lineEnding":          "LF",
 	}
 
-	result, err := c.bulkOperation(ctx, BulkOperationParams(config), jobBody)
+	result, err := c.bulkOperation(ctx, config, jobBody)
 	if err != nil {
 		return nil, fmt.Errorf("bulk write failed: %w", err)
 	}
 
-	return (*BulkOperationResult)(result), nil
+	return result, nil
 }
 
 func joinURLPath(baseURL string, paths ...string) (string, error) {
diff --git a/salesforce/errors.go b/salesforce/errors.go
index 036966ed..1e3b471a 100644
--- a/salesforce/errors.go
+++ b/salesforce/errors.go
@@ -5,8 +5,10 @@ import (
 	"errors"
 	"fmt"
 	"net/http"
+	"net/url"
 
 	"github.com/amp-labs/connectors/common"
+	"golang.org/x/oauth2"
 )
 
 var (
@@ -72,6 +74,16 @@ func (c *Connector) interpretJSONError(res *http.Response, body []byte) error {
 }
 
 func (c *Connector) HandleError(err error) error {
+	var urlErr *url.Error
+	if errors.As(err, &urlErr) {
+		var oauthErr *oauth2.RetrieveError
+		if urlErr != nil && errors.As(urlErr.Err, &oauthErr) {
+			if oauthErr.ErrorCode == "invalid_grant" {
+				return errors.Join(common.ErrInvalidGrant, err)
+			}
+		}
+	}
+
 	switch {
 	case errors.Is(err, common.ErrAccessToken):
 		// Retryable, so just log and retry
diff --git a/salesforce/read.go b/salesforce/read.go
index d2979bb0..55cbd820 100644
--- a/salesforce/read.go
+++ b/salesforce/read.go
@@ -20,7 +20,7 @@ func (c *Connector) Read(ctx context.Context, config common.ReadParams) (*common
 	if len(config.NextPage) > 0 {
 		// If NextPage is set, then we're reading the next page of results.
 		// All that matters is the NextPage URL, the fields are ignored.
-		location, joinErr := url.JoinPath("https://"+c.Domain, config.NextPage)
+		location, joinErr := url.JoinPath("https://"+c.Domain, config.NextPage.String())
 		if joinErr != nil {
 			return nil, joinErr
 		}
diff --git a/scripts/oauth/token.go b/scripts/oauth/token.go
index 5ab69d63..06f9f519 100644
--- a/scripts/oauth/token.go
+++ b/scripts/oauth/token.go
@@ -43,9 +43,13 @@ import (
 // go run token.go
 
 const (
+	HttpProtocol = "http"
+
 	DefaultCredsFile    = "creds.json"
 	DefaultServerPort   = 8080
 	DefaultCallbackPath = "/callbacks/v1/oauth"
+	DefaultSSLCert      = ".ssl/server.crt"
+	DefaultSSLKey       = ".ssl/server.key"
 
 	WaitBeforeExitSeconds    = 1
 	ReadHeaderTimeoutSeconds = 3
@@ -97,6 +101,9 @@ type OAuthApp struct {
 	Config   *oauth2.Config
 	Options  []oauth2.AuthCodeOption
 	State    string
+	Proto    string
+	SSLCert  string
+	SSLKey   string
 }
 
 // ServeHTTP implements the http.Handler interface.
@@ -163,12 +170,15 @@ func (a *OAuthApp) processCallback(writer http.ResponseWriter, request *http.Req
 		return
 	}
 
+	// Print the token which will also print raw metadata
+	fmt.Printf("%+v", tok)
+
 	writer.Header().Set("Content-Type", "application/json")
 	writer.Header().Set("Content-Length", strconv.FormatInt(int64(len(jsonBody)), 10))
 	writer.WriteHeader(http.StatusOK)
 
 	// All done
-	if _, err = writer.Write(jsonBody); err != nil { // nosemgrep
+	if _, err = writer.Write([]byte("Received a token, printed in the console")); err != nil { // nosemgrep
 		slog.Error("Error writing token", "error", err)
 
 		os.Exit(1)
@@ -189,7 +199,7 @@ func (a *OAuthApp) Run() error {
 
 	go func() {
 		time.Sleep(1 * time.Second)
-		openBrowser(fmt.Sprintf("http://localhost:%d", a.Port))
+		openBrowser(fmt.Sprintf("%s://localhost:%d", a.Proto, a.Port))
 	}()
 
 	server := &http.Server{
@@ -197,8 +207,12 @@ func (a *OAuthApp) Run() error {
 		ReadHeaderTimeout: ReadHeaderTimeoutSeconds * time.Second,
 	}
 
-	// nosemgrep: go.lang.security.audit.net.use-tls.use-tls
-	return server.ListenAndServe()
+	if a.Proto == HttpProtocol {
+		// nosemgrep: go.lang.security.audit.net.use-tls.use-tls
+		return server.ListenAndServe()
+	} else {
+		return server.ListenAndServeTLS(a.SSLCert, a.SSLKey)
+	}
 }
 
 // openBrowser tries to open the URL in a browser. Should work on most standard platforms.
@@ -227,6 +241,10 @@ func openBrowser(url string) {
 func setup() *OAuthApp {
 	// Define the CLI flags.
 	port := flag.Int("port", DefaultServerPort, "port to listen on")
+	SSLCert := flag.String("sslcert", DefaultSSLCert, "ssl certificate")
+	SSLKey := flag.String("sslkey", DefaultSSLKey, "ssl key")
+	proto := flag.String("proto", HttpProtocol, "http or https protocol")
+
 	callback := flag.String("callback", DefaultCallbackPath, "the full OAuth callback path (arbitrary)")
 	flag.Parse()
 
@@ -236,7 +254,7 @@ func setup() *OAuthApp {
 	}
 
 	// Determine the OAuth redirect URL.
-	redirect := fmt.Sprintf("http://localhost:%d%s", *port, *callback)
+	redirect := fmt.Sprintf("%s://localhost:%d%s", *proto, *port, *callback)
 
 	// Get the OAuth scopes from the flag.
 	provider := registry.MustString("Provider")
@@ -259,6 +277,9 @@ func setup() *OAuthApp {
 	app := &OAuthApp{
 		Callback: *callback,
 		Port:     *port,
+		Proto:    *proto,
+		SSLCert:  *SSLCert,
+		SSLKey:   *SSLKey,
 		Config: &oauth2.Config{
 			ClientID:     clientId,
 			ClientSecret: clientSecret,
diff --git a/scripts/proxy/proxy.go b/scripts/proxy/proxy.go
index d7b65194..91a5c1ad 100644
--- a/scripts/proxy/proxy.go
+++ b/scripts/proxy/proxy.go
@@ -13,7 +13,7 @@ import (
 	"strings"
 	"time"
 
-	"github.com/amp-labs/connectors/basic"
+	"github.com/amp-labs/connectors/connector"
 	"github.com/amp-labs/connectors/providers"
 	"github.com/amp-labs/connectors/utils"
 	"golang.org/x/oauth2"
@@ -108,10 +108,7 @@ func main() {
 	provider := registry.MustString("Provider")
 	clientId := registry.MustString("ClientId")
 	clientSecret := registry.MustString("ClientSecret")
-	accessToken := registry.MustString("AccessToken")
-	refreshToken := registry.MustString("RefreshToken")
-	atExpiry := registry.MustString("Expiry")
-	atExpiryTimeFormat := registry.MustString("ExpiryFormat")
+	tokens := getTokensFromRegistry()
 
 	scopes, err := registry.GetString("Scopes")
 	if err != nil {
@@ -131,10 +128,33 @@ func main() {
 		substitutionsMap[key] = val.MustString()
 	}
 
+	validateRequiredFlags(provider, clientId, clientSecret)
+	startProxy(provider, oauthScopes, clientId, clientSecret, substitutionsMap, DefaultPort, tokens)
+}
+
+// Some connectors may implement Refresh tokens, when it happens expiry must be provided alongside.
+// Library shouldn't attempt to refresh tokens if API doesn't support `refresh_token` grant type.
+func getTokensFromRegistry() *oauth2.Token {
+	accessToken := registry.MustString("AccessToken")
+	refreshToken, err := registry.GetString("RefreshToken")
+
+	if err != nil {
+		// we are working without refresh token
+		return &oauth2.Token{
+			AccessToken: accessToken,
+		}
+	}
+
+	// refresh token should be specified with expiry
+	atExpiry := registry.MustString("Expiry")
+	atExpiryTimeFormat := registry.MustString("ExpiryFormat")
 	expiry := parseAccessTokenExpiry(atExpiry, atExpiryTimeFormat)
 
-	validateRequiredFlags(provider, clientId, clientSecret)
-	startProxy(provider, oauthScopes, clientId, clientSecret, substitutionsMap, DefaultPort, accessToken, refreshToken, expiry)
+	return &oauth2.Token{
+		AccessToken:  accessToken,
+		RefreshToken: refreshToken,
+		Expiry:       expiry, // required: will trigger reuse of refresh token
+	}
 }
 
 func parseAccessTokenExpiry(expiryStr, timeFormat string) time.Time {
@@ -151,6 +171,7 @@ func parseAccessTokenExpiry(expiryStr, timeFormat string) time.Time {
 		"RFC3339":     time.RFC3339,
 		"RFC3339Nano": time.RFC3339Nano,
 		"Kitchen":     time.Kitchen,
+		"DateOnly":    time.DateOnly,
 	}
 
 	format, found := formatEnums[timeFormat]
@@ -175,8 +196,8 @@ func validateRequiredFlags(provider, clientId, clientSecret string) {
 	}
 }
 
-func startProxy(provider string, scopes []string, clientId, clientSecret string, substitutions map[string]string, port int, accessToken, refreshToken string, expiry time.Time) {
-	proxy := buildProxy(provider, scopes, clientId, clientSecret, substitutions, accessToken, refreshToken, expiry)
+func startProxy(provider string, scopes []string, clientId, clientSecret string, substitutions map[string]string, port int, tokens *oauth2.Token) {
+	proxy := buildProxy(provider, scopes, clientId, clientSecret, substitutions, tokens)
 	http.Handle("/", proxy)
 
 	fmt.Printf("\nProxy server listening on :%d\n", port)
@@ -186,10 +207,10 @@ func startProxy(provider string, scopes []string, clientId, clientSecret string,
 	}
 }
 
-func buildProxy(provider string, scopes []string, clientId, clientSecret string, substitutions map[string]string, accessToken, refreshToken string, expiry time.Time) *Proxy {
+func buildProxy(provider string, scopes []string, clientId, clientSecret string, substitutions map[string]string, tokens *oauth2.Token) *Proxy {
 	providerInfo := getProviderConfig(provider, substitutions)
 	cfg := configureOAuth(clientId, clientSecret, scopes, providerInfo)
-	httpClient := setupHttpClient(cfg, accessToken, refreshToken, provider, expiry, providerInfo)
+	httpClient := setupHttpClient(cfg, tokens, provider, providerInfo)
 
 	target, err := url.Parse(providerInfo.BaseURL)
 	if err != nil {
@@ -202,7 +223,7 @@ func buildProxy(provider string, scopes []string, clientId, clientSecret string,
 func getProviderConfig(provider string, substitutions map[string]string) *providers.ProviderInfo {
 	config, err := providers.ReadInfo(provider, &substitutions)
 	if err != nil {
-		panic(err)
+		panic(fmt.Errorf("%w: %s", err, provider))
 	}
 
 	return config
@@ -222,16 +243,12 @@ func configureOAuth(clientId, clientSecret string, scopes []string, providerInfo
 }
 
 // This helps with refreshing tokens automatically.
-func setupHttpClient(cfg *oauth2.Config, accessToken, refreshToken, provider string, expiry time.Time, providerInfo *providers.ProviderInfo) *http.Client {
+func setupHttpClient(cfg *oauth2.Config, tokens *oauth2.Token, provider string, providerInfo *providers.ProviderInfo) *http.Client {
 	ctx := context.Background()
 
-	conn, err := basic.NewConnector(
+	conn, err := connector.NewConnector(
 		provider,
-		basic.WithClient(ctx, http.DefaultClient, cfg, &oauth2.Token{
-			AccessToken:  accessToken,
-			RefreshToken: refreshToken,
-			Expiry:       expiry, // will trigger reuse of refresh token
-		}),
+		connector.WithClient(ctx, http.DefaultClient, cfg, tokens),
 	)
 	if err != nil {
 		panic(err)
diff --git a/utils/credentials.go b/utils/credentials.go
index fe0be17b..8f4f5eb4 100644
--- a/utils/credentials.go
+++ b/utils/credentials.go
@@ -89,3 +89,80 @@ func HubspotOAuthConfigFromRegistry(registry CredentialsRegistry) *oauth2.Config
 
 	return cfg
 }
+
+var MSDynamics365CRMWorkspace = "org5bd08fdd" //nolint:gochecknoglobals
+
+func MSDynamics365CRMConfigFromRegistry(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://login.microsoftonline.com/common/oauth2/v2.0/authorize",
+			TokenURL:  "https://login.microsoftonline.com/common/oauth2/v2.0/token",
+			AuthStyle: oauth2.AuthStyleInParams,
+		},
+		Scopes: []string{
+			fmt.Sprintf("https://%v.crm.dynamics.com/user_impersonation", MSDynamics365CRMWorkspace),
+			"offline_access",
+		},
+	}
+
+	return cfg
+}
+
+func MSDynamics365CRMTokenFromRegistry(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)
+
+	cfg := &oauth2.Config{
+		ClientID:     clientId,
+		ClientSecret: clientSecret,
+		RedirectURL:  "https://dev-api.withampersand.com/callbacks/v1/oauth",
+		Endpoint: oauth2.Endpoint{
+			AuthURL:   "https://api.outreach.io/oauth/authorize",
+			TokenURL:  "https://api.outreach.io/oauth/token",
+			AuthStyle: oauth2.AuthStyleInParams,
+		},
+		Scopes: []string{
+			"users.all",
+			"accounts.read",
+			"calls.all",
+			"events.all",
+			"teams.all",
+		},
+	}
+
+	return cfg
+}
+
+func OutreachOauthTokenFromRegistry(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
+}


Test patch:
diff --git a/microsoftdynamicscrm/delete_test.go b/microsoftdynamicscrm/delete_test.go
new file mode 100644
index 00000000..8edb9b38
--- /dev/null
+++ b/microsoftdynamicscrm/delete_test.go
@@ -0,0 +1,125 @@
+package microsoftdynamicscrm
+
+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()
+
+	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: "fax"},
+			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: "fax", RecordId: "dd2f7870-3fe8-ee11-a204-0022481f9e3c"},
+			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: "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")
+				w.WriteHeader(http.StatusBadRequest)
+				writeBody(w, `{
+					"error": {
+						"code": "0x80060888",
+						"message":"Resource not found for the segment 'conacs'."
+					}
+				}`)
+			})),
+			expectedErrs: []error{
+				common.ErrBadRequest,
+				errors.New("Resource not found for the segment 'conacs'"), // nolint:goerr113
+			},
+		},
+		{
+			name:  "Successful delete",
+			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")
+			})),
+			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),
+				WithWorkspace("test-workspace"),
+			)
+			if err != nil {
+				t.Fatalf("%s: error in test while constructin 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/microsoftdynamicscrm/errors_test.go b/microsoftdynamicscrm/errors_test.go
new file mode 100644
index 00000000..2f2cebb6
--- /dev/null
+++ b/microsoftdynamicscrm/errors_test.go
@@ -0,0 +1,95 @@
+package microsoftdynamicscrm
+
+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
+		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,
+		},
+		{
+			// TODO should it indicate that connector could handle it?
+			// TODO It would be bad to mask partial API implementation with actual caller error
+			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,
+		},
+	}
+
+	connector := Connector{}
+
+	for _, tt := range tests {
+		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)
+			if !errors.Is(err, tt.expectedErr) {
+				t.Fatalf("%s: expected: (%v), got: (%v)", tt.name, tt.expectedErr, err)
+			}
+		})
+	}
+
+	t.Run("Correct interpretation of error payload", func(t *testing.T) {
+		t.Parallel()
+
+		err := connector.interpretJSONError(&http.Response{
+			StatusCode: http.StatusBadRequest,
+		}, []byte(`{  
+				 "error":{  
+				  "code": "<This code is not related to the http status code and is frequently empty>",  
+				  "message": "<A message describing the error>"  
+				 }  
+				}`))
+		if !strings.Contains(err.Error(), "<A message describing the error>") {
+			t.Fatalf("expected errot type mismatched for: (%v)", err)
+		}
+	})
+}
diff --git a/microsoftdynamicscrm/metadata_test.go b/microsoftdynamicscrm/metadata_test.go
new file mode 100644
index 00000000..63c1b346
--- /dev/null
+++ b/microsoftdynamicscrm/metadata_test.go
@@ -0,0 +1,217 @@
+package microsoftdynamicscrm
+
+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 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)
+	}
+
+	tests := []struct {
+		name                string
+		input               []string
+		server              *httptest.Server
+		connector           Connector
+		expected            *common.ListObjectMetadataResult
+		expectedFieldsCount map[string]int // used instead of `expected` when response result is too big
+		expectedErrs        []error
+	}{
+		{
+			name:  "At least one object name must be queried",
+			input: nil,
+			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: []string{"msfp_surveyinvite"},
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.WriteHeader(http.StatusTeapot)
+			})),
+			expectedErrs: []error{interpreter.ErrMissingContentType},
+		},
+		{
+			name:  "Missing XML response on status OK",
+			input: []string{"msfp_surveyinvite"},
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/xml")
+				w.WriteHeader(http.StatusOK)
+				writeBody(w, "")
+			})),
+			expectedErrs: []error{common.ErrNotXML},
+		},
+		{
+			name:  "Missing XML root",
+			input: []string{"msfp_surveyinvite"},
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/xml")
+				w.WriteHeader(http.StatusOK)
+				writeBody(w, `<?xml version="1.0" encoding="utf-8"?>`)
+			})),
+			expectedErrs: []error{common.ErrNoXMLRoot},
+		},
+		{
+			name:  "Server response without CRM Schema",
+			input: []string{"msfp_surveyinvite"},
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/xml")
+				w.WriteHeader(http.StatusOK)
+				writeBody(w, `
+<?xml version="1.0" encoding="utf-8"?>
+<edmx:Edmx Version="4.0" xmlns:edmx="http://docs.oasis-open.org/odata/ns/edmx"></edmx:Edmx>`)
+			})),
+			expectedErrs: []error{ErrMissingSchema},
+		},
+		{
+			name:  "Object name cannot be found from server response",
+			input: []string{"msfp_surveyinvite"},
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/xml")
+				w.WriteHeader(http.StatusOK)
+				_, _ = w.Write(fakeServerResp)
+			})),
+			expectedErrs: []error{ErrObjectNotFound, errors.New("unknown entity msfp_surveyinvite")}, // nolint:goerr113
+		},
+		{
+			name:  "Correctly list metadata for account leads and invite contact",
+			input: []string{"accountleads", "adx_invitation_invitecontacts"},
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/xml")
+				w.WriteHeader(http.StatusOK)
+				_, _ = w.Write(fakeServerResp)
+			})),
+			expected: &common.ListObjectMetadataResult{
+				Result: map[string]common.ObjectMetadata{
+					"accountleads": {
+						DisplayName: "accountleads",
+						FieldsMap: map[string]string{
+							"accountid":                 "accountid",
+							"accountleadid":             "accountleadid",
+							"importsequencenumber":      "importsequencenumber",
+							"leadid":                    "leadid",
+							"name":                      "name",
+							"overriddencreatedon":       "overriddencreatedon",
+							"timezoneruleversionnumber": "timezoneruleversionnumber",
+							"utcconversiontimezonecode": "utcconversiontimezonecode",
+							"versionnumber":             "versionnumber",
+						},
+					},
+					"adx_invitation_invitecontacts": {
+						DisplayName: "adx_invitation_invitecontacts",
+						FieldsMap: map[string]string{
+							"adx_invitation_invitecontactsid": "adx_invitation_invitecontactsid",
+							"adx_invitationid":                "adx_invitationid",
+							"contactid":                       "contactid",
+							"versionnumber":                   "versionnumber",
+						},
+					},
+				},
+				Errors: nil,
+			},
+			expectedErrs: nil,
+		},
+		{
+			// In total phonecall will have 65 fields, where
+			// phonecall 		(has 7 fields) and inherits from
+			// activitypointer 	(has 58 fields), which in turn inherits from
+			// crmbaseentity 	(has 0 fields)
+			name:  "Correctly list metadata for phone calls including inherited fields",
+			input: []string{"phonecall", "activitypointer", "crmbaseentity"},
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/xml")
+				w.WriteHeader(http.StatusOK)
+				_, _ = w.Write(fakeServerResp)
+			})),
+			expectedFieldsCount: map[string]int{
+				"phonecall":       65,
+				"activitypointer": 58,
+				"crmbaseentity":   0,
+			},
+			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()
+
+			connector, err := NewConnector(
+				WithAuthenticatedClient(http.DefaultClient),
+				WithWorkspace("test-workspace"),
+			)
+			if err != nil {
+				t.Fatalf("%s: error in test while constructin 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.ListObjectMetadata(context.Background(), 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
+			// there are 2 modes we can compare, by exact field values or matching quantity
+			if len(tt.expectedFieldsCount) != 0 {
+				// we are comparing if number of fields match under ListObjectMetadataResult.Result
+				for entityName, count := range tt.expectedFieldsCount {
+					entity, ok := output.Result[entityName]
+					if !ok {
+						t.Fatalf("%s: expected entity was missing: (%v)", tt.name, entityName)
+					}
+
+					got := len(entity.FieldsMap)
+					if got != count {
+						t.Fatalf("%s: expected entity '%v' to have (%v) fields got: (%v)",
+							tt.name, entityName, count, got)
+					}
+				}
+			} else { // nolint:gocritic
+				// usual comparison of ListObjectMetadataResult
+				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/microsoftdynamicscrm/read_test.go b/microsoftdynamicscrm/read_test.go
new file mode 100644
index 00000000..1e314035
--- /dev/null
+++ b/microsoftdynamicscrm/read_test.go
@@ -0,0 +1,292 @@
+package microsoftdynamicscrm
+
+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"
+	"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)
+	}
+
+	tests := []struct {
+		name             string
+		input            common.ReadParams
+		server           *httptest.Server
+		connector        Connector
+		expected         *common.ReadResult
+		expectedErrs     []error
+		expectedErrTypes []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.StatusBadRequest)
+				writeBody(w, `{
+					"error": {
+						"code": "0x80060888",
+						"message":"Resource not found for the segment 'conacs'."
+					}
+				}`)
+			})),
+			expectedErrs: []error{
+				common.ErrBadRequest, errors.New("Resource not found for the segment 'conacs'"), // 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)
+				writeBody(w, `{
+					"garbage": {}
+				}`)
+			})),
+			expectedErrTypes: []error{ajson.Error{}},
+		},
+		{
+			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)
+				writeBody(w, `{
+					"value": {}
+				}`)
+			})),
+			expectedErrs: []error{common.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)
+				writeBody(w, `{
+					"value": []
+				}`)
+			})),
+			expected: &common.ReadResult{
+				Data: []common.ReadResultRow{},
+				Done: true,
+			},
+			expectedErrs: nil,
+		},
+		{
+			name: "Successful read with 2 entries",
+			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(fakeServerResp)
+			})),
+			expected: &common.ReadResult{
+				Rows: 2,
+				Data: []common.ReadResultRow{{
+					Fields: map[string]any{},
+					Raw: map[string]any{
+						"@odata.etag":   "W/\"4372108\"",
+						"fullname":      "Heriberto Nathan",
+						"emailaddress1": "heriberto@northwindtraders.com",
+						"fax":           "614-555-0122",
+						"familystatuscode@OData.Community.Display.V1.FormattedValue": "Single",
+						"familystatuscode": float64(1),
+						"contactid":        "cdcfa450-cb0c-ea11-a813-000d3a1b1223",
+					},
+				}, {
+					Fields: map[string]any{},
+					Raw: map[string]any{
+						"@odata.etag":   "W/\"4372115\"",
+						"fullname":      "Dwayne Elijah",
+						"emailaddress1": "dwayne@alpineskihouse.com",
+						"fax":           "281-555-0158",
+						"familystatuscode@OData.Community.Display.V1.FormattedValue": "Single",
+						"familystatuscode": float64(1),
+						"contactid":        "9fd4a450-cb0c-ea11-a813-000d3a1b1223",
+					},
+				}},
+				NextPage: "https://org5bd08fdd.api.crm.dynamics.com/api/data/v9.2/contacts?$select=fullname,emailaddress1,fax,familystatuscode&$skiptoken=%3Ccookie%20pagenumber=%222%22%20pagingcookie=%22%253ccookie%2520page%253d%25221%2522%253e%253ccontactid%2520last%253d%2522%257b9FD4A450-CB0C-EA11-A813-000D3A1B1223%257d%2522%2520first%253d%2522%257bCDCFA450-CB0C-EA11-A813-000D3A1B1223%257d%2522%2520%252f%253e%253c%252fcookie%253e%22%20istracking=%22False%22%20/%3E", // nolint:lll
+				Done:     false,
+			},
+			expectedErrs: nil,
+		},
+		{
+			name: "Successful read with chosen fields",
+			input: common.ReadParams{
+				Fields: []string{"fullname", "fax"},
+			},
+			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(fakeServerResp)
+			})),
+			expected: &common.ReadResult{
+				Rows: 2,
+				Data: []common.ReadResultRow{{
+					Fields: map[string]any{
+						"fullname": "Heriberto Nathan",
+						"fax":      "614-555-0122",
+					},
+					Raw: map[string]any{
+						"@odata.etag":   "W/\"4372108\"",
+						"fullname":      "Heriberto Nathan",
+						"emailaddress1": "heriberto@northwindtraders.com",
+						"fax":           "614-555-0122",
+						"familystatuscode@OData.Community.Display.V1.FormattedValue": "Single",
+						"familystatuscode": float64(1),
+						"contactid":        "cdcfa450-cb0c-ea11-a813-000d3a1b1223",
+					},
+				}, {
+					Fields: map[string]any{
+						"fullname": "Dwayne Elijah",
+						"fax":      "281-555-0158",
+					},
+					Raw: map[string]any{
+						"@odata.etag":   "W/\"4372115\"",
+						"fullname":      "Dwayne Elijah",
+						"emailaddress1": "dwayne@alpineskihouse.com",
+						"fax":           "281-555-0158",
+						"familystatuscode@OData.Community.Display.V1.FormattedValue": "Single",
+						"familystatuscode": float64(1),
+						"contactid":        "9fd4a450-cb0c-ea11-a813-000d3a1b1223",
+					},
+				}},
+				NextPage: "https://org5bd08fdd.api.crm.dynamics.com/api/data/v9.2/contacts?$select=fullname,emailaddress1,fax,familystatuscode&$skiptoken=%3Ccookie%20pagenumber=%222%22%20pagingcookie=%22%253ccookie%2520page%253d%25221%2522%253e%253ccontactid%2520last%253d%2522%257b9FD4A450-CB0C-EA11-A813-000D3A1B1223%257d%2522%2520first%253d%2522%257bCDCFA450-CB0C-EA11-A813-000D3A1B1223%257d%2522%2520%252f%253e%253c%252fcookie%253e%22%20istracking=%22False%22%20/%3E", // nolint:lll
+				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),
+				WithWorkspace("test-workspace"),
+			)
+			if err != nil {
+				t.Fatalf("%s: error in test while constructin 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)+len(tt.expectedErrTypes) == 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)
+				}
+
+				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)
+				}
+			}
+
+			// compare desired output
+			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)
+			}
+		})
+	}
+}
+
+func writeBody(w http.ResponseWriter, body string) {
+	_, _ = w.Write([]byte(body))
+}
diff --git a/microsoftdynamicscrm/test/metadata.xml b/microsoftdynamicscrm/test/metadata.xml
new file mode 100644
index 00000000..4308e0f7
--- /dev/null
+++ b/microsoftdynamicscrm/test/metadata.xml
@@ -0,0 +1,578 @@
+<?xml version="1.0" encoding="utf-8"?>
+<edmx:Edmx Version="4.0" xmlns:edmx="http://docs.oasis-open.org/odata/ns/edmx">
+    <edmx:Reference Uri="https://vocabularies.odata.org/OData.Community.Keys.V1.xml">
+        <edmx:Include Namespace="OData.Community.Keys.V1" Alias="Keys"/>
+        <edmx:IncludeAnnotations TermNamespace="OData.Community.Keys.V1"/>
+    </edmx:Reference>
+    <edmx:Reference Uri="https://vocabularies.odata.org/OData.Community.Display.V1.xml">
+        <edmx:Include Namespace="OData.Community.Display.V1" Alias="Display"/>
+        <edmx:IncludeAnnotations TermNamespace="OData.Community.Display.V1"/>
+    </edmx:Reference>
+    <edmx:DataServices>
+        <Schema Namespace="Microsoft.Dynamics.CRM" Alias="mscrm" xmlns="http://docs.oasis-open.org/odata/ns/edm">
+            <EntityType Name="crmbaseentity" Abstract="true"/>
+            <EntityType Name="accountleads" BaseType="mscrm.crmbaseentity">
+                <Key>
+                    <PropertyRef Name="accountleadid"/>
+                </Key>
+                <Property Name="importsequencenumber" Type="Edm.Int32"/>
+                <Property Name="accountleadid" Type="Edm.Guid"/>
+                <Property Name="name" Type="Edm.String" Unicode="false"/>
+                <Property Name="accountid" Type="Edm.Guid"/>
+                <Property Name="overriddencreatedon" Type="Edm.DateTimeOffset"/>
+                <Property Name="versionnumber" Type="Edm.Int64"/>
+                <Property Name="leadid" Type="Edm.Guid"/>
+                <Property Name="timezoneruleversionnumber" Type="Edm.Int32"/>
+                <Property Name="utcconversiontimezonecode" Type="Edm.Int32"/>
+                <NavigationProperty Name="accountleads_AsyncOperations" Type="Collection(mscrm.asyncoperation)"
+                                    Partner="regardingobjectid_accountleads"/>
+                <NavigationProperty Name="accountleads_MailboxTrackingFolders"
+                                    Type="Collection(mscrm.mailboxtrackingfolder)"
+                                    Partner="regardingobjectid_accountleads"/>
+                <NavigationProperty Name="accountleads_BulkDeleteFailures" Type="Collection(mscrm.bulkdeletefailure)"
+                                    Partner="regardingobjectid_accountleads"/>
+                <NavigationProperty Name="accountleads_PrincipalObjectAttributeAccesses"
+                                    Type="Collection(mscrm.principalobjectattributeaccess)"
+                                    Partner="objectid_accountleads"/>
+            </EntityType>
+            <EntityType Name="adx_invitation_invitecontacts" BaseType="mscrm.crmbaseentity">
+                <Key>
+                    <PropertyRef Name="adx_invitation_invitecontactsid"/>
+                </Key>
+                <Property Name="adx_invitationid" Type="Edm.Guid"/>
+                <Property Name="versionnumber" Type="Edm.Int64"/>
+                <Property Name="contactid" Type="Edm.Guid"/>
+                <Property Name="adx_invitation_invitecontactsid" Type="Edm.Guid"/>
+            </EntityType>
+            <EntityType Name="phonecall" BaseType="mscrm.activitypointer">
+                <Key>
+                    <PropertyRef Name="activityid"/>
+                </Key>
+                <Property Name="subcategory" Type="Edm.String" Unicode="false"/>
+                <Property Name="directioncode" Type="Edm.Boolean"/>
+                <Property Name="overriddencreatedon" Type="Edm.DateTimeOffset"/>
+                <Property Name="category" Type="Edm.String" Unicode="false"/>
+                <Property Name="subscriptionid" Type="Edm.Guid"/>
+                <Property Name="phonenumber" Type="Edm.String" Unicode="false"/>
+                <Property Name="importsequencenumber" Type="Edm.Int32"/>
+                <NavigationProperty Name="phonecall_PostRegardings" Type="Collection(mscrm.postregarding)"
+                                    Partner="regardingobjectid_phonecall"/>
+                <NavigationProperty Name="phonecall_PostFollows" Type="Collection(mscrm.postfollow)"
+                                    Partner="regardingobjectid_phonecall"/>
+                <NavigationProperty Name="regardingobjectid_knowledgebaserecord_phonecall"
+                                    Type="mscrm.knowledgebaserecord"
+                                    Nullable="false" Partner="KnowledgeBaseRecord_PhoneCalls"/>
+                <NavigationProperty Name="phonecall_actioncard" Type="Collection(mscrm.actioncard)"
+                                    Partner="regardingobjectid_phonecall_actioncard"/>
+                <NavigationProperty Name="transactioncurrencyid_phonecall" Type="mscrm.transactioncurrency"
+                                    Nullable="false"
+                                    Partner="TransactionCurrency_PhoneCall"/>
+                <NavigationProperty Name="regardingobjectid_contact_phonecall" Type="mscrm.contact" Nullable="false"
+                                    Partner="Contact_Phonecalls"/>
+                <NavigationProperty Name="sla_phonecall_sla" Type="mscrm.sla" Nullable="false"
+                                    Partner="manualsla_phonecall"/>
+                <NavigationProperty Name="PhoneCall_BulkDeleteFailures" Type="Collection(mscrm.bulkdeletefailure)"
+                                    Partner="regardingobjectid_phonecall"/>
+                <NavigationProperty Name="modifiedonbehalfby_phonecall" Type="mscrm.systemuser" Nullable="false"
+                                    Partner="lk_phonecall_modifiedonbehalfby"/>
+                <NavigationProperty Name="PhoneCall_AsyncOperations" Type="Collection(mscrm.asyncoperation)"
+                                    Partner="regardingobjectid_phonecall"/>
+                <NavigationProperty Name="regardingobjectid_account_phonecall" Type="mscrm.account" Nullable="false"
+                                    Partner="Account_Phonecalls"/>
+                <NavigationProperty Name="createdby_phonecall" Type="mscrm.systemuser" Nullable="false"
+                                    Partner="lk_phonecall_createdby"/>
+                <NavigationProperty Name="createdonbehalfby_phonecall" Type="mscrm.systemuser" Nullable="false"
+                                    Partner="lk_phonecall_createdonbehalfby"/>
+                <NavigationProperty Name="phonecall_activity_parties" Type="Collection(mscrm.activityparty)"
+                                    Partner="activityid_phonecall"/>
+                <NavigationProperty Name="PhoneCall_ProcessSessions" Type="Collection(mscrm.processsession)"
+                                    Partner="regardingobjectid_phonecall"/>
+                <NavigationProperty Name="owningbusinessunit_phonecall" Type="mscrm.businessunit" Nullable="false"
+                                    Partner="business_unit_phone_call_activities"/>
+                <NavigationProperty Name="PhoneCall_DuplicateMatchingRecord" Type="Collection(mscrm.duplicaterecord)"
+                                    Partner="duplicaterecordid_phonecall"/>
+                <NavigationProperty Name="regardingobjectid_knowledgearticle_phonecall" Type="mscrm.knowledgearticle"
+                                    Nullable="false" Partner="KnowledgeArticle_Phonecalls"/>
+                <NavigationProperty Name="stageid_processstage" Type="mscrm.processstage" Nullable="false"
+                                    Partner="processstage_phonecalls"/>
+                <NavigationProperty Name="PhoneCall_DuplicateBaseRecord" Type="Collection(mscrm.duplicaterecord)"
+                                    Partner="baserecordid_phonecall"/>
+                <NavigationProperty Name="phonecall_connections1" Type="Collection(mscrm.connection)"
+                                    Partner="record1id_phonecall"/>
+                <NavigationProperty Name="activityid_activitypointer" Type="mscrm.activitypointer" Nullable="false"
+                                    Partner="activity_pointer_phonecall"/>
+                <NavigationProperty Name="slakpiinstance_phonecall" Type="Collection(mscrm.slakpiinstance)"
+                                    Partner="regarding_phonecall"/>
+                <NavigationProperty Name="slainvokedid_phonecall_sla" Type="mscrm.sla" Nullable="false"
+                                    Partner="sla_phonecall"/>
+                <NavigationProperty Name="PhoneCall_SyncErrors" Type="Collection(mscrm.syncerror)"
+                                    Partner="regardingobjectid_phonecall_syncerror"/>
+                <NavigationProperty Name="modifiedby_phonecall" Type="mscrm.systemuser" Nullable="false"
+                                    Partner="lk_phonecall_modifiedby"/>
+                <NavigationProperty Name="owningteam_phonecall" Type="mscrm.team" Nullable="false"
+                                    Partner="team_phonecall"/>
+                <NavigationProperty Name="PhoneCall_QueueItem" Type="Collection(mscrm.queueitem)"
+                                    Partner="objectid_phonecall"/>
+                <NavigationProperty Name="PhoneCall_Annotation" Type="Collection(mscrm.annotation)"
+                                    Partner="objectid_phonecall"/>
+                <NavigationProperty Name="phonecall_connections2" Type="Collection(mscrm.connection)"
+                                    Partner="record2id_phonecall"/>
+                <NavigationProperty Name="owninguser_phonecall" Type="mscrm.systemuser" Nullable="false"
+                                    Partner="user_phonecall"/>
+                <NavigationProperty Name="phonecall_principalobjectattributeaccess"
+                                    Type="Collection(mscrm.principalobjectattributeaccess)"
+                                    Partner="objectid_phonecall"/>
+                <NavigationProperty Name="ownerid_phonecall" Type="mscrm.principal" Nullable="false"
+                                    Partner="owner_phonecalls"/>
+                <NavigationProperty Name="regardingobjectid_adx_invitation_phonecall" Type="mscrm.adx_invitation"
+                                    Nullable="false"
+                                    Partner="adx_invitation_PhoneCalls"/>
+                <NavigationProperty Name="regardingobjectid_mspp_adplacement_phonecall" Type="mscrm.mspp_adplacement"
+                                    Nullable="false" Partner="mspp_adplacement_PhoneCalls"/>
+                <NavigationProperty Name="regardingobjectid_mspp_pollplacement_phonecall"
+                                    Type="mscrm.mspp_pollplacement"
+                                    Nullable="false" Partner="mspp_pollplacement_PhoneCalls"/>
+                <NavigationProperty Name="regardingobjectid_mspp_publishingstatetransitionrule_phonecall"
+                                    Type="mscrm.mspp_publishingstatetransitionrule" Nullable="false"
+                                    Partner="mspp_publishingstatetransitionrule_PhoneCalls"/>
+                <NavigationProperty Name="regardingobjectid_mspp_redirect_phonecall" Type="mscrm.mspp_redirect"
+                                    Nullable="false"
+                                    Partner="mspp_redirect_PhoneCalls"/>
+                <NavigationProperty Name="regardingobjectid_mspp_shortcut_phonecall" Type="mscrm.mspp_shortcut"
+                                    Nullable="false"
+                                    Partner="mspp_shortcut_PhoneCalls"/>
+                <NavigationProperty Name="regardingobjectid_mspp_website_phonecall" Type="mscrm.mspp_website"
+                                    Nullable="false"
+                                    Partner="mspp_website_PhoneCalls"/>
+                <NavigationProperty Name="regardingobjectid_lead_phonecall" Type="mscrm.lead" Nullable="false"
+                                    Partner="Lead_Phonecalls"/>
+                <NavigationProperty Name="regardingobjectid_bookableresourcebooking_phonecall"
+                                    Type="mscrm.bookableresourcebooking"
+                                    Nullable="false" Partner="bookableresourcebooking_PhoneCalls"/>
+                <NavigationProperty Name="regardingobjectid_bookableresourcebookingheader_phonecall"
+                                    Type="mscrm.bookableresourcebookingheader" Nullable="false"
+                                    Partner="bookableresourcebookingheader_PhoneCalls"/>
+                <NavigationProperty Name="regardingobjectid_bulkoperation_phonecall" Type="mscrm.bulkoperation"
+                                    Nullable="false"
+                                    Partner="BulkOperation_Phonecall"/>
+                <NavigationProperty Name="regardingobjectid_campaign_phonecall" Type="mscrm.campaign" Nullable="false"
+                                    Partner="Campaign_Phonecalls"/>
+                <NavigationProperty Name="regardingobjectid_campaignactivity_phonecall" Type="mscrm.campaignactivity"
+                                    Nullable="false" Partner="CampaignActivity_Phonecalls"/>
+                <NavigationProperty Name="phonecall_campaignresponse" Type="Collection(mscrm.campaignresponse)"
+                                    Partner="originatingactivityid_phonecall"/>
+                <NavigationProperty Name="regardingobjectid_contract_phonecall" Type="mscrm.contract" Nullable="false"
+                                    Partner="Contract_Phonecalls"/>
+                <NavigationProperty Name="regardingobjectid_entitlement_phonecall" Type="mscrm.entitlement"
+                                    Nullable="false"
+                                    Partner="entitlement_PhoneCalls"/>
+                <NavigationProperty Name="regardingobjectid_entitlementtemplate_phonecall"
+                                    Type="mscrm.entitlementtemplate"
+                                    Nullable="false" Partner="entitlementtemplate_PhoneCalls"/>
+                <NavigationProperty Name="regardingobjectid_incident_phonecall" Type="mscrm.incident" Nullable="false"
+                                    Partner="Incident_Phonecalls"/>
+                <NavigationProperty Name="regardingobjectid_site_phonecall" Type="mscrm.site" Nullable="false"
+                                    Partner="site_PhoneCalls"/>
+                <NavigationProperty Name="regardingobjectid_msdyn_customerasset_phonecall"
+                                    Type="mscrm.msdyn_customerasset"
+                                    Nullable="false" Partner="msdyn_customerasset_PhoneCalls"/>
+                <NavigationProperty Name="regardingobjectid_msdyn_playbookinstance_phonecall"
+                                    Type="mscrm.msdyn_playbookinstance"
+                                    Nullable="false" Partner="msdyn_playbookinstance_PhoneCalls"/>
+                <NavigationProperty Name="regardingobjectid_invoice_phonecall" Type="mscrm.invoice" Nullable="false"
+                                    Partner="Invoice_Phonecalls"/>
+                <NavigationProperty Name="regardingobjectid_opportunity_phonecall" Type="mscrm.opportunity"
+                                    Nullable="false"
+                                    Partner="Opportunity_Phonecalls"/>
+                <NavigationProperty Name="regardingobjectid_quote_phonecall" Type="mscrm.quote" Nullable="false"
+                                    Partner="Quote_Phonecalls"/>
+                <NavigationProperty Name="regardingobjectid_salesorder_phonecall" Type="mscrm.salesorder"
+                                    Nullable="false"
+                                    Partner="SalesOrder_Phonecalls"/>
+                <NavigationProperty Name="regardingobjectid_msdyn_postalbum_phonecall" Type="mscrm.msdyn_postalbum"
+                                    Nullable="false"
+                                    Partner="msdyn_postalbum_PhoneCalls"/>
+                <NavigationProperty Name="regardingobjectid_msdyn_swarm_phonecall" Type="mscrm.msdyn_swarm"
+                                    Nullable="false"
+                                    Partner="msdyn_swarm_PhoneCalls"/>
+                <NavigationProperty Name="msdyn_phonecall_msdyn_transcript_transcripttarget"
+                                    Type="Collection(mscrm.msdyn_transcript)"
+                                    Partner="msdyn_transcripttarget_phonecall"/>
+                <NavigationProperty Name="msdyn_phonecall_msdyn_ocrecording_recordingtarget"
+                                    Type="Collection(mscrm.msdyn_ocrecording)"
+                                    Partner="msdyn_recordingtarget_phonecall"/>
+                <NavigationProperty Name="msdyn_phonecall_msdyn_conversationinsight_conversationinsighttarget"
+                                    Type="Collection(mscrm.msdyn_conversationinsight)"
+                                    Partner="msdyn_conversationinsighttarget_phonecall"/>
+                <NavigationProperty Name="msdyn_msdyn_conversationactionitem_phonecall_msdyn_createdactivity"
+                                    Type="Collection(mscrm.msdyn_conversationactionitem)"
+                                    Partner="msdyn_createdactivity_phonecall"/>
+                <NavigationProperty Name="msdyn_msdyn_sciconversation_phonecall_msdyn_relatedactivity"
+                                    Type="Collection(mscrm.msdyn_sciconversation)"
+                                    Partner="msdyn_relatedactivity_phonecall"/>
+                <NavigationProperty Name="msdyn_phonecall_msdyn_recording_phone_call_activity"
+                                    Type="Collection(mscrm.msdyn_recording)" Partner="msdyn_phone_call_activity"/>
+                <NavigationProperty Name="regardingobjectid_msdyn_salessuggestion_phonecall"
+                                    Type="mscrm.msdyn_salessuggestion"
+                                    Nullable="false" Partner="msdyn_salessuggestion_PhoneCalls"/>
+                <NavigationProperty Name="msdyn_msdyn_salescopilotinsight_phonecall_msdyn_activityid"
+                                    Type="Collection(mscrm.msdyn_salescopilotinsight)"
+                                    Partner="msdyn_activityid_phonecall"/>
+                <NavigationProperty Name="serviceid_phonecall" Type="mscrm.service" Nullable="false"
+                                    Partner="service_phonecalls"/>
+            </EntityType>
+            <EntityType Name="activitypointer" BaseType="mscrm.crmbaseentity" Abstract="true">
+                <Key>
+                    <PropertyRef Name="activityid"/>
+                </Key>
+                <Property Name="_transactioncurrencyid_value" Type="Edm.Guid"/>
+                <Property Name="instancetypecode" Type="Edm.Int32"/>
+                <Property Name="_modifiedby_value" Type="Edm.Guid"/>
+                <Property Name="_createdby_value" Type="Edm.Guid"/>
+                <Property Name="onholdtime" Type="Edm.Int32"/>
+                <Property Name="description" Type="Edm.String" Unicode="false"/>
+                <Property Name="seriesid" Type="Edm.Guid"/>
+                <Property Name="_ownerid_value" Type="Edm.Guid"/>
+                <Property Name="lastonholdtime" Type="Edm.DateTimeOffset"/>
+                <Property Name="_owningteam_value" Type="Edm.Guid"/>
+                <Property Name="activitytypecode" Type="Edm.String" Unicode="false"/>
+                <Property Name="deliverylastattemptedon" Type="Edm.DateTimeOffset"/>
+                <Property Name="exchangerate" Type="Edm.Decimal" Scale="Variable"/>
+                <Property Name="isbilled" Type="Edm.Boolean"/>
+                <Property Name="leftvoicemail" Type="Edm.Boolean"/>
+                <Property Name="postponeactivityprocessinguntil" Type="Edm.DateTimeOffset"/>
+                <Property Name="_owningbusinessunit_value" Type="Edm.Guid"/>
+                <Property Name="traversedpath" Type="Edm.String" Unicode="false"/>
+                <Property Name="scheduledstart" Type="Edm.DateTimeOffset"/>
+                <Property Name="descriptionblobid" Type="Edm.Binary"/>
+                <Property Name="activityadditionalparams" Type="Edm.String" Unicode="false"/>
+                <Property Name="subject" Type="Edm.String" Unicode="false"/>
+                <Property Name="scheduleddurationminutes" Type="Edm.Int32"/>
+                <Property Name="_serviceid_value" Type="Edm.Guid"/>
+                <Property Name="versionnumber" Type="Edm.Int64"/>
+                <Property Name="community" Type="Edm.Int32"/>
+                <Property Name="_createdonbehalfby_value" Type="Edm.Guid"/>
+                <Property Name="_slainvokedid_value" Type="Edm.Guid"/>
+                <Property Name="_regardingobjectid_value" Type="Edm.Guid"/>
+                <Property Name="actualdurationminutes" Type="Edm.Int32"/>
+                <Property Name="exchangeweblink" Type="Edm.String" Unicode="false"/>
+                <Property Name="senton" Type="Edm.DateTimeOffset"/>
+                <Property Name="isworkflowcreated" Type="Edm.Boolean"/>
+                <Property Name="scheduledend" Type="Edm.DateTimeOffset"/>
+                <Property Name="isregularactivity" Type="Edm.Boolean"/>
+                <Property Name="_slaid_value" Type="Edm.Guid"/>
+                <Property Name="_owninguser_value" Type="Edm.Guid"/>
+                <Property Name="ismapiprivate" Type="Edm.Boolean"/>
+                <Property Name="activityid" Type="Edm.Guid"/>
+                <Property Name="formattedscheduledend" Type="Edm.DateTimeOffset"/>
+                <Property Name="exchangeitemid" Type="Edm.String" Unicode="false"/>
+                <Property Name="processid" Type="Edm.Guid"/>
+                <Property Name="_sendermailboxid_value" Type="Edm.Guid"/>
+                <Property Name="stageid" Type="Edm.Guid"/>
+                <Property Name="modifiedon" Type="Edm.DateTimeOffset"/>
+                <Property Name="createdon" Type="Edm.DateTimeOffset"/>
+                <Property Name="formattedscheduledstart" Type="Edm.DateTimeOffset"/>
+                <Property Name="statuscode" Type="Edm.Int32"/>
+                <Property Name="statecode" Type="Edm.Int32"/>
+                <Property Name="actualend" Type="Edm.DateTimeOffset"/>
+                <Property Name="_modifiedonbehalfby_value" Type="Edm.Guid"/>
+                <Property Name="actualstart" Type="Edm.DateTimeOffset"/>
+                <Property Name="prioritycode" Type="Edm.Int32"/>
+                <Property Name="descriptionblobid_name" Type="Edm.String" Unicode="false"/>
+                <Property Name="deliveryprioritycode" Type="Edm.Int32"/>
+                <Property Name="sortdate" Type="Edm.DateTimeOffset"/>
+                <Property Name="timezoneruleversionnumber" Type="Edm.Int32"/>
+                <Property Name="utcconversiontimezonecode" Type="Edm.Int32"/>
+                <NavigationProperty Name="regardingobjectid_new_interactionforemail" Type="mscrm.interactionforemail"
+                                    Nullable="false"
+                                    Partner="new_interactionforemail_ActivityPointers">
+                    <ReferentialConstraint Property="_regardingobjectid_value"
+                                           ReferencedProperty="interactionforemailid"/>
+                </NavigationProperty>
+                <NavigationProperty Name="regardingobjectid_knowledgebaserecord" Type="mscrm.knowledgebaserecord"
+                                    Nullable="false"
+                                    Partner="KnowledgeBaseRecord_ActivityPointers">
+                    <ReferentialConstraint Property="_regardingobjectid_value"
+                                           ReferencedProperty="knowledgebaserecordid"/>
+                </NavigationProperty>
+                <NavigationProperty Name="regardingobjectid_account" Type="mscrm.account" Nullable="false"
+                                    Partner="Account_ActivityPointers">
+                    <ReferentialConstraint Property="_regardingobjectid_value" ReferencedProperty="accountid"/>
+                </NavigationProperty>
+                <NavigationProperty Name="createdby" Type="mscrm.systemuser" Nullable="false"
+                                    Partner="lk_activitypointer_createdby">
+                    <ReferentialConstraint Property="_createdby_value" ReferencedProperty="systemuserid"/>
+                </NavigationProperty>
+                <NavigationProperty Name="regardingobjectid_contact" Type="mscrm.contact" Nullable="false"
+                                    Partner="Contact_ActivityPointers">
+                    <ReferentialConstraint Property="_regardingobjectid_value" ReferencedProperty="contactid"/>
+                </NavigationProperty>
+                <NavigationProperty Name="activity_pointer_socialactivity" Type="Collection(mscrm.socialactivity)"
+                                    Partner="activityid_activitypointer"/>
+                <NavigationProperty Name="activity_pointer_recurringappointmentmaster"
+                                    Type="Collection(mscrm.recurringappointmentmaster)"
+                                    Partner="activityid_activitypointer"/>
+                <NavigationProperty Name="activity_pointer_email" Type="Collection(mscrm.email)"
+                                    Partner="activityid_activitypointer"/>
+                <NavigationProperty Name="sendermailboxid" Type="mscrm.mailbox" Nullable="false"
+                                    Partner="activitypointer_sendermailboxid_mailbox">
+                    <ReferentialConstraint Property="_sendermailboxid_value" ReferencedProperty="mailboxid"/>
+                </NavigationProperty>
+                <NavigationProperty Name="transactioncurrencyid" Type="mscrm.transactioncurrency" Nullable="false"
+                                    Partner="TransactionCurrency_ActivityPointer">
+                    <ReferentialConstraint Property="_transactioncurrencyid_value"
+                                           ReferencedProperty="transactioncurrencyid"/>
+                </NavigationProperty>
+                <NavigationProperty Name="ActivityPointer_QueueItem" Type="Collection(mscrm.queueitem)"
+                                    Partner="objectid_activitypointer"/>
+                <NavigationProperty Name="ownerid" Type="mscrm.principal" Nullable="false"
+                                    Partner="owner_activitypointers">
+                    <ReferentialConstraint Property="_ownerid_value" ReferencedProperty="ownerid"/>
+                </NavigationProperty>
+                <NavigationProperty Name="owninguser" Type="mscrm.systemuser" Nullable="false" Partner="user_activity">
+                    <ReferentialConstraint Property="_owninguser_value" ReferencedProperty="systemuserid"/>
+                </NavigationProperty>
+                <NavigationProperty Name="sla_activitypointer_sla" Type="mscrm.sla" Nullable="false"
+                                    Partner="manualsla_activitypointer">
+                    <ReferentialConstraint Property="_slaid_value" ReferencedProperty="slaid"/>
+                </NavigationProperty>
+                <NavigationProperty Name="owningbusinessunit" Type="mscrm.businessunit" Nullable="false"
+                                    Partner="business_unit_activitypointer">
+                    <ReferentialConstraint Property="_owningbusinessunit_value" ReferencedProperty="businessunitid"/>
+                </NavigationProperty>
+                <NavigationProperty Name="regardingobjectid_knowledgearticle" Type="mscrm.knowledgearticle"
+                                    Nullable="false"
+                                    Partner="KnowledgeArticle_ActivityPointers">
+                    <ReferentialConstraint Property="_regardingobjectid_value" ReferencedProperty="knowledgearticleid"/>
+                </NavigationProperty>
+                <NavigationProperty Name="modifiedonbehalfby" Type="mscrm.systemuser" Nullable="false"
+                                    Partner="lk_activitypointer_modifiedonbehalfby">
+                    <ReferentialConstraint Property="_modifiedonbehalfby_value" ReferencedProperty="systemuserid"/>
+                </NavigationProperty>
+                <NavigationProperty Name="activity_pointer_activity_mime_attachment"
+                                    Type="Collection(mscrm.activitymimeattachment)"
+                                    Partner="objectid_activitypointer"/>
+                <NavigationProperty Name="activity_pointer_fax" Type="Collection(mscrm.fax)"
+                                    Partner="activityid_activitypointer"/>
+                <NavigationProperty Name="createdonbehalfby" Type="mscrm.systemuser" Nullable="false"
+                                    Partner="lk_activitypointer_createdonbehalfby">
+                    <ReferentialConstraint Property="_createdonbehalfby_value" ReferencedProperty="systemuserid"/>
+                </NavigationProperty>
+                <NavigationProperty Name="modifiedby" Type="mscrm.systemuser" Nullable="false"
+                                    Partner="lk_activitypointer_modifiedby">
+                    <ReferentialConstraint Property="_modifiedby_value" ReferencedProperty="systemuserid"/>
+                </NavigationProperty>
+                <NavigationProperty Name="activity_pointer_task" Type="Collection(mscrm.task)"
+                                    Partner="activityid_activitypointer"/>
+                <NavigationProperty Name="activity_pointer_phonecall" Type="Collection(mscrm.phonecall)"
+                                    Partner="activityid_activitypointer"/>
+                <NavigationProperty Name="activity_pointer_appointment" Type="Collection(mscrm.appointment)"
+                                    Partner="activityid_activitypointer"/>
+                <NavigationProperty Name="activity_pointer_letter" Type="Collection(mscrm.letter)"
+                                    Partner="activityid_activitypointer"/>
+                <NavigationProperty Name="activitypointer_connections2" Type="Collection(mscrm.connection)"
+                                    Partner="record2id_activitypointer"/>
+                <NavigationProperty Name="slakpiinstance_activitypointer" Type="Collection(mscrm.slakpiinstance)"
+                                    Partner="regarding_activitypointer"/>
+                <NavigationProperty Name="owningteam" Type="mscrm.team" Nullable="false" Partner="team_activity">
+                    <ReferentialConstraint Property="_owningteam_value" ReferencedProperty="teamid"/>
+                </NavigationProperty>
+                <NavigationProperty Name="ActivityPointer_BulkDeleteFailures" Type="Collection(mscrm.bulkdeletefailure)"
+                                    Partner="regardingobjectid_activitypointer"/>
+                <NavigationProperty Name="slainvokedid_activitypointer_sla" Type="mscrm.sla" Nullable="false"
+                                    Partner="sla_activitypointer">
+                    <ReferentialConstraint Property="_slainvokedid_value" ReferencedProperty="slaid"/>
+                </NavigationProperty>
+                <NavigationProperty Name="activitypointer_connections1" Type="Collection(mscrm.connection)"
+                                    Partner="record1id_activitypointer"/>
+                <NavigationProperty Name="ActivityPointer_AsyncOperations" Type="Collection(mscrm.asyncoperation)"
+                                    Partner="regardingobjectid_activitypointer"/>
+                <NavigationProperty Name="activity_pointer_recurrencerule" Type="Collection(mscrm.recurrencerule)"
+                                    Partner="objectid"/>
+                <NavigationProperty Name="activitypointer_activity_parties" Type="Collection(mscrm.activityparty)"
+                                    Partner="activityid_activitypointer"/>
+                <NavigationProperty Name="activity_pointer_chat" Type="Collection(mscrm.chat)"
+                                    Partner="activityid_chat"/>
+                <NavigationProperty Name="activitypointer_FileAttachments" Type="Collection(mscrm.fileattachment)"
+                                    Partner="objectid_activitypointer"/>
+                <NavigationProperty Name="regardingobjectid_adx_invitation" Type="mscrm.adx_invitation" Nullable="false"
+                                    Partner="adx_invitation_ActivityPointers">
+                    <ReferentialConstraint Property="_regardingobjectid_value" ReferencedProperty="adx_invitationid"/>
+                </NavigationProperty>
+                <NavigationProperty Name="activity_pointer_adx_inviteredemption"
+                                    Type="Collection(mscrm.adx_inviteredemption)"
+                                    Partner="activityid_adx_inviteredemption"/>
+                <NavigationProperty Name="activity_pointer_adx_portalcomment" Type="Collection(mscrm.adx_portalcomment)"
+                                    Partner="activityid_adx_portalcomment"/>
+                <NavigationProperty Name="regardingobjectid_mspp_adplacement" Type="mscrm.mspp_adplacement"
+                                    Nullable="false"
+                                    Partner="mspp_adplacement_ActivityPointers">
+                    <ReferentialConstraint Property="_regardingobjectid_value" ReferencedProperty="mspp_adplacementid"/>
+                </NavigationProperty>
+                <NavigationProperty Name="regardingobjectid_mspp_pollplacement" Type="mscrm.mspp_pollplacement"
+                                    Nullable="false"
+                                    Partner="mspp_pollplacement_ActivityPointers">
+                    <ReferentialConstraint Property="_regardingobjectid_value"
+                                           ReferencedProperty="mspp_pollplacementid"/>
+                </NavigationProperty>
+                <NavigationProperty Name="regardingobjectid_mspp_publishingstatetransitionrule"
+                                    Type="mscrm.mspp_publishingstatetransitionrule" Nullable="false"
+                                    Partner="mspp_publishingstatetransitionrule_ActivityPointers">
+                    <ReferentialConstraint Property="_regardingobjectid_value"
+                                           ReferencedProperty="mspp_publishingstatetransitionruleid"/>
+                </NavigationProperty>
+                <NavigationProperty Name="regardingobjectid_mspp_redirect" Type="mscrm.mspp_redirect" Nullable="false"
+                                    Partner="mspp_redirect_ActivityPointers">
+                    <ReferentialConstraint Property="_regardingobjectid_value" ReferencedProperty="mspp_redirectid"/>
+                </NavigationProperty>
+                <NavigationProperty Name="regardingobjectid_mspp_shortcut" Type="mscrm.mspp_shortcut" Nullable="false"
+                                    Partner="mspp_shortcut_ActivityPointers">
+                    <ReferentialConstraint Property="_regardingobjectid_value" ReferencedProperty="mspp_shortcutid"/>
+                </NavigationProperty>
+                <NavigationProperty Name="regardingobjectid_mspp_website" Type="mscrm.mspp_website" Nullable="false"
+                                    Partner="mspp_website_ActivityPointers">
+                    <ReferentialConstraint Property="_regardingobjectid_value" ReferencedProperty="mspp_websiteid"/>
+                </NavigationProperty>
+                <NavigationProperty Name="regardingobjectid_lead" Type="mscrm.lead" Nullable="false"
+                                    Partner="Lead_ActivityPointers">
+                    <ReferentialConstraint Property="_regardingobjectid_value" ReferencedProperty="leadid"/>
+                </NavigationProperty>
+                <NavigationProperty Name="regardingobjectid_bookableresourcebooking"
+                                    Type="mscrm.bookableresourcebooking"
+                                    Nullable="false" Partner="bookableresourcebooking_ActivityPointers">
+                    <ReferentialConstraint Property="_regardingobjectid_value"
+                                           ReferencedProperty="bookableresourcebookingid"/>
+                </NavigationProperty>
+                <NavigationProperty Name="regardingobjectid_bookableresourcebookingheader"
+                                    Type="mscrm.bookableresourcebookingheader"
+                                    Nullable="false" Partner="bookableresourcebookingheader_ActivityPointers">
+                    <ReferentialConstraint Property="_regardingobjectid_value"
+                                           ReferencedProperty="bookableresourcebookingheaderid"/>
+                </NavigationProperty>
+                <NavigationProperty Name="activity_pointer_BulkOperation" Type="Collection(mscrm.bulkoperation)"
+                                    Partner="activityid_activitypointer"/>
+                <NavigationProperty Name="regardingobjectid_campaign" Type="mscrm.campaign" Nullable="false"
+                                    Partner="Campaign_ActivityPointers">
+                    <ReferentialConstraint Property="_regardingobjectid_value" ReferencedProperty="campaignid"/>
+                </NavigationProperty>
+                <NavigationProperty Name="activity_pointer_campaignactivity" Type="Collection(mscrm.campaignactivity)"
+                                    Partner="activityid_activitypointer"/>
+                <NavigationProperty Name="activity_pointer_campaignresponse" Type="Collection(mscrm.campaignresponse)"
+                                    Partner="activityid_activitypointer"/>
+                <NavigationProperty Name="activity_pointer_BulkOperation_logs" Type="Collection(mscrm.bulkoperationlog)"
+                                    Partner="bulkoperationid_activitypointer"/>
+                <NavigationProperty Name="CreatedActivity_BulkOperationLogs" Type="Collection(mscrm.bulkoperationlog)"
+                                    Partner="createdobjectid_activitypointer"/>
+                <NavigationProperty Name="ActivityPointer_CampaignActivityItems"
+                                    Type="Collection(mscrm.campaignactivityitem)"
+                                    Partner="campaignactivityid"/>
+                <NavigationProperty Name="activity_campaignresponse" Type="Collection(mscrm.campaignresponse)"
+                                    Partner="originatingactivityid_activitypointer"/>
+                <NavigationProperty Name="regardingobjectid_campaignactivity" Type="mscrm.campaignactivity"
+                                    Nullable="false"
+                                    Partner="CampaignActivity_ActivityPointers"/>
+                <NavigationProperty Name="regardingobjectid_bulkoperation" Type="mscrm.bulkoperation" Nullable="false"
+                                    Partner="BulkOperation_ActivityPointers"/>
+                <NavigationProperty Name="regardingobjectid_entitlement" Type="mscrm.entitlement" Nullable="false"
+                                    Partner="entitlement_ActivityPointers">
+                    <ReferentialConstraint Property="_regardingobjectid_value" ReferencedProperty="entitlementid"/>
+                </NavigationProperty>
+                <NavigationProperty Name="regardingobjectid_entitlementtemplate" Type="mscrm.entitlementtemplate"
+                                    Nullable="false"
+                                    Partner="entitlementtemplate_ActivityPointers">
+                    <ReferentialConstraint Property="_regardingobjectid_value"
+                                           ReferencedProperty="entitlementtemplateid"/>
+                </NavigationProperty>
+                <NavigationProperty Name="regardingobjectid_site" Type="mscrm.site" Nullable="false"
+                                    Partner="site_ActivityPointers">
+                    <ReferentialConstraint Property="_regardingobjectid_value" ReferencedProperty="siteid"/>
+                </NavigationProperty>
+                <NavigationProperty Name="regardingobjectid_msdyn_customerasset" Type="mscrm.msdyn_customerasset"
+                                    Nullable="false"
+                                    Partner="msdyn_customerasset_ActivityPointers">
+                    <ReferentialConstraint Property="_regardingobjectid_value"
+                                           ReferencedProperty="msdyn_customerassetid"/>
+                </NavigationProperty>
+                <NavigationProperty Name="regardingobjectid_msdyn_playbookinstance" Type="mscrm.msdyn_playbookinstance"
+                                    Nullable="false"
+                                    Partner="msdyn_playbookinstance_ActivityPointers">
+                    <ReferentialConstraint Property="_regardingobjectid_value"
+                                           ReferencedProperty="msdyn_playbookinstanceid"/>
+                </NavigationProperty>
+                <NavigationProperty Name="regardingobjectid_invoice" Type="mscrm.invoice" Nullable="false"
+                                    Partner="Invoice_ActivityPointers">
+                    <ReferentialConstraint Property="_regardingobjectid_value" ReferencedProperty="invoiceid"/>
+                </NavigationProperty>
+                <NavigationProperty Name="regardingobjectid_opportunity" Type="mscrm.opportunity" Nullable="false"
+                                    Partner="Opportunity_ActivityPointers">
+                    <ReferentialConstraint Property="_regardingobjectid_value" ReferencedProperty="opportunityid"/>
+                </NavigationProperty>
+                <NavigationProperty Name="activity_pointer_opportunity_close" Type="Collection(mscrm.opportunityclose)"
+                                    Partner="activityid_activitypointer"/>
+                <NavigationProperty Name="activity_pointer_order_close" Type="Collection(mscrm.orderclose)"
+                                    Partner="activityid_activitypointer"/>
+                <NavigationProperty Name="activity_pointer_quote_close" Type="Collection(mscrm.quoteclose)"
+                                    Partner="activityid_activitypointer"/>
+                <NavigationProperty Name="regardingobjectid_quote" Type="mscrm.quote" Nullable="false"
+                                    Partner="Quote_ActivityPointers">
+                    <ReferentialConstraint Property="_regardingobjectid_value" ReferencedProperty="quoteid"/>
+                </NavigationProperty>
+                <NavigationProperty Name="regardingobjectid_salesorder" Type="mscrm.salesorder" Nullable="false"
+                                    Partner="SalesOrder_ActivityPointers">
+                    <ReferentialConstraint Property="_regardingobjectid_value" ReferencedProperty="salesorderid"/>
+                </NavigationProperty>
+                <NavigationProperty Name="msdyn_readtracker_poly_activitypointer"
+                                    Type="Collection(mscrm.msdyn_readtracker)"
+                                    Partner="msdyn_RegardingObjectId"/>
+                <NavigationProperty Name="regardingobjectid_msdyn_postalbum" Type="mscrm.msdyn_postalbum"
+                                    Nullable="false"
+                                    Partner="msdyn_postalbum_ActivityPointers">
+                    <ReferentialConstraint Property="_regardingobjectid_value" ReferencedProperty="msdyn_postalbumid"/>
+                </NavigationProperty>
+                <NavigationProperty Name="activity_pointer_msfp_alert" Type="Collection(mscrm.msfp_alert)"
+                                    Partner="activityid_msfp_alert"/>
+                <NavigationProperty Name="activity_pointer_msfp_surveyinvite" Type="Collection(mscrm.msfp_surveyinvite)"
+                                    Partner="activityid_msfp_surveyinvite"/>
+                <NavigationProperty Name="activity_pointer_msfp_surveyresponse"
+                                    Type="Collection(mscrm.msfp_surveyresponse)"
+                                    Partner="activityid_msfp_surveyresponse"/>
+                <NavigationProperty Name="activity_pointer_msdyn_copilottranscript"
+                                    Type="Collection(mscrm.msdyn_copilottranscript)"
+                                    Partner="activityid_msdyn_copilottranscript"/>
+                <NavigationProperty Name="regardingobjectid_msdyn_swarm" Type="mscrm.msdyn_swarm" Nullable="false"
+                                    Partner="msdyn_swarm_ActivityPointers">
+                    <ReferentialConstraint Property="_regardingobjectid_value" ReferencedProperty="msdyn_swarmid"/>
+                </NavigationProperty>
+                <NavigationProperty Name="activity_pointer_msdyn_ocliveworkitem"
+                                    Type="Collection(mscrm.msdyn_ocliveworkitem)"
+                                    Partner="activityid_msdyn_ocliveworkitem"/>
+                <NavigationProperty Name="activity_pointer_msdyn_ocsession" Type="Collection(mscrm.msdyn_ocsession)"
+                                    Partner="activityid_msdyn_ocsession"/>
+                <NavigationProperty Name="regardingobjectid_msdyn_salessuggestion" Type="mscrm.msdyn_salessuggestion"
+                                    Nullable="false"
+                                    Partner="msdyn_salessuggestion_ActivityPointers">
+                    <ReferentialConstraint Property="_regardingobjectid_value"
+                                           ReferencedProperty="msdyn_salessuggestionid"/>
+                </NavigationProperty>
+                <NavigationProperty Name="activity_pointer_incident_resolution"
+                                    Type="Collection(mscrm.incidentresolution)"
+                                    Partner="activityid_activitypointer"/>
+                <NavigationProperty Name="regardingobjectid_incident" Type="mscrm.incident" Nullable="false"
+                                    Partner="Incident_ActivityPointers">
+                    <ReferentialConstraint Property="_regardingobjectid_value" ReferencedProperty="incidentid"/>
+                </NavigationProperty>
+                <NavigationProperty Name="serviceid" Type="mscrm.service" Nullable="false"
+                                    Partner="service_activity_pointers">
+                    <ReferentialConstraint Property="_serviceid_value" ReferencedProperty="serviceid"/>
+                </NavigationProperty>
+                <NavigationProperty Name="activity_pointer_service_appointment"
+                                    Type="Collection(mscrm.serviceappointment)"
+                                    Partner="activityid_activitypointer"/>
+                <NavigationProperty Name="regardingobjectid_contract" Type="mscrm.contract" Nullable="false"
+                                    Partner="Contract_ActivityPointers">
+                    <ReferentialConstraint Property="_regardingobjectid_value" ReferencedProperty="contractid"/>
+                </NavigationProperty>
+            </EntityType>
+        </Schema>
+    </edmx:DataServices>
+</edmx:Edmx>
\ No newline at end of file
diff --git a/microsoftdynamicscrm/test/read.json b/microsoftdynamicscrm/test/read.json
new file mode 100644
index 00000000..56227274
--- /dev/null
+++ b/microsoftdynamicscrm/test/read.json
@@ -0,0 +1,27 @@
+{
+  "@odata.context": "https://org5bd08fdd.api.crm.dynamics.com/api/data/v9.2/$metadata#contacts(fullname,emailaddress1,fax,familystatuscode)",
+  "@Microsoft.Dynamics.CRM.totalrecordcount": -1,
+  "@Microsoft.Dynamics.CRM.totalrecordcountlimitexceeded": false,
+  "@Microsoft.Dynamics.CRM.globalmetadataversion": "6012567",
+  "value": [
+    {
+      "@odata.etag": "W/\"4372108\"",
+      "fullname": "Heriberto Nathan",
+      "emailaddress1": "heriberto@northwindtraders.com",
+      "fax": "614-555-0122",
+      "familystatuscode@OData.Community.Display.V1.FormattedValue": "Single",
+      "familystatuscode": 1,
+      "contactid": "cdcfa450-cb0c-ea11-a813-000d3a1b1223"
+    },
+    {
+      "@odata.etag": "W/\"4372115\"",
+      "fullname": "Dwayne Elijah",
+      "emailaddress1": "dwayne@alpineskihouse.com",
+      "fax": "281-555-0158",
+      "familystatuscode@OData.Community.Display.V1.FormattedValue": "Single",
+      "familystatuscode": 1,
+      "contactid": "9fd4a450-cb0c-ea11-a813-000d3a1b1223"
+    }
+  ],
+  "@odata.nextLink": "https://org5bd08fdd.api.crm.dynamics.com/api/data/v9.2/contacts?$select=fullname,emailaddress1,fax,familystatuscode&$skiptoken=%3Ccookie%20pagenumber=%222%22%20pagingcookie=%22%253ccookie%2520page%253d%25221%2522%253e%253ccontactid%2520last%253d%2522%257b9FD4A450-CB0C-EA11-A813-000D3A1B1223%257d%2522%2520first%253d%2522%257bCDCFA450-CB0C-EA11-A813-000D3A1B1223%257d%2522%2520%252f%253e%253c%252fcookie%253e%22%20istracking=%22False%22%20/%3E"
+}
diff --git a/microsoftdynamicscrm/write_test.go b/microsoftdynamicscrm/write_test.go
new file mode 100644
index 00000000..07cb5d4d
--- /dev/null
+++ b/microsoftdynamicscrm/write_test.go
@@ -0,0 +1,127 @@
+package microsoftdynamicscrm
+
+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()
+
+	tests := []struct {
+		name         string
+		input        common.WriteParams
+		server       *httptest.Server
+		connector    Connector
+		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: "fax"},
+			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: "fax"},
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				w.WriteHeader(http.StatusBadRequest)
+				writeBody(w, `{
+					"error": {
+						"code": "0x80060888",
+						"message":"Resource not found for the segment 'conacs'."
+					}
+				}`)
+			})),
+			expectedErrs: []error{
+				common.ErrBadRequest,
+				errors.New("Resource not found for the segment 'conacs'"), // nolint:goerr113
+			},
+		},
+		{
+			name:  "Write must act as a Create",
+			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")
+			})),
+			expected:     &common.WriteResult{Success: true},
+			expectedErrs: nil,
+		},
+		{
+			name:  "Write must act as an Update",
+			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")
+			})),
+			expected:     &common.WriteResult{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),
+				WithWorkspace("test-workspace"),
+			)
+			if err != nil {
+				t.Fatalf("%s: error in test while constructin 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)
+				}
+			}
+
+			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/providers/catalog_test.go b/providers/catalog_test.go
index f57b8121..6d08361b 100644
--- a/providers/catalog_test.go
+++ b/providers/catalog_test.go
@@ -28,7 +28,7 @@ var testCases = []struct { // nolint
 					Insert: false,
 					Update: false,
 					Upsert: true,
-					Delete: false,
+					Delete: true,
 				},
 				Subscribe: false,
 				Proxy:     true,
@@ -153,6 +153,9 @@ var testCases = []struct { // nolint
 				TokenURL:                  "https://accounts.salesloft.com/oauth/token",
 				ExplicitScopesRequired:    false,
 				ExplicitWorkspaceRequired: false,
+				TokenMetadataFields: TokenMetadataFields{
+					ScopesField: "scope",
+				},
 			},
 			BaseURL: "https://api.salesloft.com",
 		},
@@ -172,7 +175,7 @@ var testCases = []struct { // nolint
 					Delete: false,
 				},
 				Subscribe: false,
-				Proxy:     false,
+				Proxy:     true,
 			},
 			AuthType: Oauth2,
 			OauthOpts: OauthOpts{
@@ -180,8 +183,53 @@ var testCases = []struct { // nolint
 				TokenURL:                  "https://api.outreach.io/oauth/token",
 				ExplicitScopesRequired:    true,
 				ExplicitWorkspaceRequired: false,
+				TokenMetadataFields: TokenMetadataFields{
+					ScopesField: "scope",
+				},
 			},
 			BaseURL: "https://api.outreach.io",
+			ProviderOpts: ProviderOpts{
+				"restAPIURL": "https://api.outreach.io/api/v2",
+			},
+		},
+		expectedErr: nil,
+	},
+
+	// RingCentral
+
+	{
+		provider:    RingCentral,
+		description: "Valid RingCentral provider config without 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{
+				GrantType: PKCE,
+
+				AuthURL:  "https://platform.ringcentral.com/restapi/oauth/authorize",
+				TokenURL: "https://platform.ringcentral.com/restapi/oauth/token",
+
+				ExplicitScopesRequired:    false,
+				ExplicitWorkspaceRequired: false,
+				TokenMetadataFields: TokenMetadataFields{
+					ScopesField:      "scope",
+					ConsumerRefField: "owner_id",
+				},
+			},
+			BaseURL: "https://platform.ringcentral.com",
 		},
 		expectedErr: nil,
 	},
@@ -240,6 +288,39 @@ var testCases = []struct { // nolint
 		expectedErr: nil,
 	},
 
+	// Wrike provider
+
+	{
+		provider:    Wrike,
+		description: "Valid Wrike provider config with no substitutions",
+		expected: &ProviderInfo{
+			AuthType: Oauth2,
+			OauthOpts: OauthOpts{
+				AuthURL:                   "https://www.wrike.com/oauth2/authorize",
+				TokenURL:                  "https://www.wrike.com/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://www.wrike.com/api",
+		},
+		expectedErr: nil,
+	},
+
 	{
 		provider:    Copper,
 		description: "Valid Copper provider config with no substitutions",
@@ -296,11 +377,75 @@ var testCases = []struct { // nolint
 		expectedErr: nil,
 	},
 
+	{
+		provider:    Mural,
+		description: "Valid Mural provider 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://api.mural.co/oauth/authorize",
+				TokenURL:                  "https://api.mural.co/oauth/token",
+				ExplicitScopesRequired:    true,
+				ExplicitWorkspaceRequired: false,
+				TokenMetadataFields: TokenMetadataFields{
+					ScopesField: "scope",
+				},
+			},
+			BaseURL: "https://api.mural.co/api",
+		},
+		expectedErr: nil,
+	},
+
+	{
+		provider:    Klaviyo,
+		description: "Valid Klaviyo provider config with no substitutions",
+		expected: &ProviderInfo{
+			Support: Support{
+				BulkWrite: BulkWriteSupport{
+					Insert: false,
+					Update: false,
+					Upsert: false,
+					Delete: false,
+				},
+				Proxy:     false,
+				Read:      false,
+				Subscribe: false,
+				Write:     false,
+			},
+			AuthType: Oauth2,
+			OauthOpts: OauthOpts{
+				GrantType:                 "PKCE",
+				AuthURL:                   "https://www.klaviyo.com/oauth/authorize",
+				TokenURL:                  "https://a.klaviyo.com/oauth/token",
+				ExplicitScopesRequired:    true,
+				ExplicitWorkspaceRequired: false,
+				TokenMetadataFields: TokenMetadataFields{
+					ScopesField: "scope",
+				},
+			},
+			BaseURL: "https://a.klaviyo.com",
+		},
+		expectedErr: nil,
+	},
+
 	{
 		provider: Sellsy,
 		expected: &ProviderInfo{
 			AuthType: Oauth2,
 			OauthOpts: OauthOpts{
+				GrantType:                 PKCE,
 				AuthURL:                   "https://login.sellsy.com/oauth2/authorization",
 				TokenURL:                  "https://login.sellsy.com/oauth2/access-tokens",
 				ExplicitScopesRequired:    false,
@@ -433,6 +578,9 @@ var testCases = []struct { // nolint
 				TokenURL:                  "https://app.asana.com/-/oauth_token",
 				ExplicitScopesRequired:    false,
 				ExplicitWorkspaceRequired: false,
+				TokenMetadataFields: TokenMetadataFields{
+					ConsumerRefField: "data.id",
+				},
 			},
 			BaseURL: "https://app.asana.com/api",
 		},
@@ -460,7 +608,7 @@ var testCases = []struct { // nolint
 				Subscribe: false,
 				Write:     false,
 			},
-			BaseURL: "https://api.dropboxapi.com/2/",
+			BaseURL: "https://api.dropboxapi.com",
 		},
 		expectedErr: nil,
 	},
@@ -497,10 +645,7 @@ var testCases = []struct { // nolint
 	},
 	{
 		provider:    Gong,
-		description: "Gong provider config with valid substitutions",
-		substitutions: map[string]string{
-			"workspace": "testing",
-		},
+		description: "Gong provider config without substitutions",
 		expected: &ProviderInfo{
 			Support: Support{
 				Read:  false,
@@ -520,8 +665,12 @@ var testCases = []struct { // nolint
 				TokenURL:                  "https://app.gong.io/oauth2/generate-customer-token",
 				ExplicitWorkspaceRequired: false,
 				ExplicitScopesRequired:    true,
+				TokenMetadataFields: TokenMetadataFields{
+					ScopesField:      "scope",
+					ConsumerRefField: "client_id",
+				},
 			},
-			BaseURL: "https://testing.api.gong.io",
+			BaseURL: "https://api.gong.io",
 		},
 		expectedErr: nil,
 	},
@@ -579,37 +728,38 @@ 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: DocuSign,
-		substitutions: map[string]string{
-			"workspace": "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: true,
-			},
-			BaseURL: "https://example.docusign.net",
-		},
-		expectedErr: nil,
-	},
-	{
-		provider: DocuSignDeveloper,
+		provider: DocusignDeveloper,
 		expected: &ProviderInfo{
 			Support: Support{
 				Read:  false,
@@ -652,6 +802,7 @@ var testCases = []struct { // nolint
 			},
 			AuthType: Oauth2,
 			OauthOpts: OauthOpts{
+				GrantType:                 AuthorizationCode,
 				AuthURL:                   "https://auth.calendly.com/oauth/authorize",
 				TokenURL:                  "https://auth.calendly.com/oauth/token",
 				ExplicitScopesRequired:    false,
@@ -716,8 +867,8 @@ var testCases = []struct { // nolint
 		expectedErr: nil,
 	},
 	{
-		provider:    MicrosoftDynamics365Sales,
-		description: "MS Dynamics 365 Sales provider config with valid substitutions",
+		provider:    MicrosoftDynamics365CRM,
+		description: "MS Dynamics 365 CRM provider config with valid substitutions",
 		substitutions: map[string]string{
 			"workspace": "testing",
 		},
@@ -736,6 +887,7 @@ var testCases = []struct { // nolint
 			},
 			AuthType: Oauth2,
 			OauthOpts: OauthOpts{
+				GrantType:                 AuthorizationCode,
 				AuthURL:                   "https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
 				TokenURL:                  "https://login.microsoftonline.com/common/oauth2/v2.0/token",
 				ExplicitScopesRequired:    true,
@@ -853,6 +1005,7 @@ var testCases = []struct { // nolint
 			},
 			AuthType: Oauth2,
 			OauthOpts: OauthOpts{
+				GrantType:                 AuthorizationCode,
 				AuthURL:                   "https://account.box.com/api/oauth2/authorize",
 				TokenURL:                  "https://api.box.com/oauth2/token",
 				ExplicitScopesRequired:    false,
@@ -862,36 +1015,6 @@ var testCases = []struct { // nolint
 		},
 		expectedErr: nil,
 	},
-	{
-		provider:    GoogleCalendar,
-		description: "Google Calendar provider 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://accounts.google.com/o/oauth2/v2/auth",
-				TokenURL:                  "https://oauth2.googleapis.com/token",
-				ExplicitWorkspaceRequired: false,
-				ExplicitScopesRequired:    true,
-				TokenMetadataFields: TokenMetadataFields{
-					ScopesField: "scope",
-				},
-			},
-			BaseURL: "https://www.googleapis.com/calendar",
-		},
-		expectedErr: nil,
-	},
 	{
 		provider:    ZendeskSupport,
 		description: "Zendesk Support provider config with valid substitutions",
@@ -909,10 +1032,11 @@ var testCases = []struct { // nolint
 					Delete: false,
 				},
 				Subscribe: false,
-				Proxy:     false,
+				Proxy:     true,
 			},
 			AuthType: Oauth2,
 			OauthOpts: OauthOpts{
+				GrantType:                 AuthorizationCode,
 				AuthURL:                   "https://testing.zendesk.com/oauth/authorizations/new",
 				TokenURL:                  "https://testing.zendesk.com/oauth/tokens",
 				ExplicitScopesRequired:    true,
@@ -931,6 +1055,7 @@ var testCases = []struct { // nolint
 		expected: &ProviderInfo{
 			AuthType: Oauth2,
 			OauthOpts: OauthOpts{
+				GrantType:                 AuthorizationCode,
 				AuthURL:                   "https://www.zopim.com/oauth2/authorizations/new?subdomain=test",
 				TokenURL:                  "https://www.zopim.com/oauth2/token",
 				ExplicitScopesRequired:    true,
@@ -1010,6 +1135,495 @@ var testCases = []struct { // nolint
 		},
 		expectedErr: nil,
 	},
+	{
+		provider:    Slack,
+		description: "Valid Slack provider config with non-existent 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{
+				GrantType:                 AuthorizationCode,
+				AuthURL:                   "https://slack.com/oauth/v2/authorize",
+				TokenURL:                  "https://slack.com/api/oauth.v2.access",
+				ExplicitScopesRequired:    true,
+				ExplicitWorkspaceRequired: true,
+				TokenMetadataFields: TokenMetadataFields{
+					ScopesField:       "scope",
+					WorkspaceRefField: "workspace_name",
+				},
+			},
+			BaseURL: "https://slack.com/api",
+		},
+		expectedErr: nil,
+	},
+	{
+		provider:    HelpScoutMailbox,
+		description: "Valid HelpScoutMailbox provider config with no substitutions",
+		expected: &ProviderInfo{
+			AuthType: Oauth2,
+			OauthOpts: OauthOpts{
+				GrantType:                 AuthorizationCode,
+				AuthURL:                   "https://secure.helpscout.net/authentication/authorizeClientApplication",
+				TokenURL:                  "https://api.helpscout.net/v2/oauth2/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.helpscout.net",
+		},
+		expectedErr: nil,
+	},
+	{
+		provider:    Timely,
+		description: "Valid Timely provider config with no substitutions",
+		expected: &ProviderInfo{
+			AuthType: Oauth2,
+			OauthOpts: OauthOpts{
+				GrantType:                 AuthorizationCode,
+				AuthURL:                   "https://api.timelyapp.com/1.1/oauth/authorize",
+				TokenURL:                  "https://api.timelyapp.com/1.1/oauth/token",
+				ExplicitScopesRequired:    false,
+				ExplicitWorkspaceRequired: false,
+				TokenMetadataFields: TokenMetadataFields{
+					ScopesField: "scope",
+				},
+			},
+			Support: Support{
+				Read:  false,
+				Write: false,
+				BulkWrite: BulkWriteSupport{
+					Insert: false,
+					Update: false,
+					Upsert: false,
+					Delete: false,
+				},
+				Subscribe: false,
+				Proxy:     false,
+			},
+			BaseURL: "https://api.timelyapp.com",
+		},
+		expectedErr: nil,
+	},
+	{
+		provider:    Atlassian,
+		description: "Valid Atlassian provider config with no substitutions",
+		expected: &ProviderInfo{
+			AuthType: Oauth2,
+			OauthOpts: OauthOpts{
+				GrantType:                 AuthorizationCode,
+				AuthURL:                   "https://auth.atlassian.com/authorize",
+				TokenURL:                  "https://auth.atlassian.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,
+			},
+			BaseURL: "https://api.atlassian.com",
+		},
+		expectedErr: nil,
+	},
+	{
+		provider:    Webflow,
+		description: "Valid Webflow provider config with no substitutions",
+		expected: &ProviderInfo{
+			AuthType: Oauth2,
+			OauthOpts: OauthOpts{
+				GrantType:                 AuthorizationCode,
+				AuthURL:                   "https://webflow.com/oauth/authorize",
+				TokenURL:                  "https://api.webflow.com/oauth/access_token",
+				ExplicitScopesRequired:    true,
+				ExplicitWorkspaceRequired: false,
+				TokenMetadataFields: TokenMetadataFields{
+					ScopesField: "scope",
+				},
+			},
+			Support: Support{
+				BulkWrite: BulkWriteSupport{
+					Insert: false,
+					Update: false,
+					Upsert: false,
+					Delete: false,
+				},
+				Proxy:     true,
+				Read:      false,
+				Subscribe: false,
+				Write:     false,
+			},
+			BaseURL: "https://api.webflow.com",
+		},
+		expectedErr: nil,
+	},
+	{
+		provider:    Smartsheet,
+		description: "Valid Smartsheet provider config with no substitutions",
+		expected: &ProviderInfo{
+			AuthType: Oauth2,
+			OauthOpts: OauthOpts{
+				GrantType:                 AuthorizationCode,
+				AuthURL:                   "https://app.smartsheet.com/b/authorize",
+				TokenURL:                  "https://api.smartsheet.com/2.0/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.smartsheet.com",
+		},
+		expectedErr: nil,
+	},
+	{
+		provider:    StackExchange,
+		description: "Valid StackExchange provider config with non-existent 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{
+				GrantType:                 AuthorizationCode,
+				AuthURL:                   "https://stackoverflow.com/oauth",
+				TokenURL:                  "https://stackoverflow.com/oauth/access_token/json",
+				ExplicitScopesRequired:    true,
+				ExplicitWorkspaceRequired: false,
+				TokenMetadataFields: TokenMetadataFields{
+					ScopesField: "scope",
+				},
+			},
+			BaseURL: "https://api.stackexchange.com",
+		},
+		expectedErr: nil,
+	},
+	{
+		provider:    Google,
+		description: "Valid Google provider config with no substitutions",
+		expected: &ProviderInfo{
+			AuthType: Oauth2,
+			OauthOpts: OauthOpts{
+				GrantType:                 AuthorizationCode,
+				AuthURL:                   "https://accounts.google.com/o/oauth2/v2/auth",
+				TokenURL:                  "https://oauth2.googleapis.com/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://www.googleapis.com",
+		},
+		expectedErr: nil,
+	},
+	{
+		provider:    GoogleContacts,
+		description: "Valid GoogleContacts provider config with no substitutions",
+		expected: &ProviderInfo{
+			AuthType: Oauth2,
+			OauthOpts: OauthOpts{
+				GrantType:                 AuthorizationCode,
+				AuthURL:                   "https://accounts.google.com/o/oauth2/v2/auth",
+				TokenURL:                  "https://oauth2.googleapis.com/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://people.googleapis.com",
+		},
+		expectedErr: nil,
+	},
+	{
+		provider:    GoogleMail,
+		description: "Valid GoogleMail provider config with no substitutions",
+		expected: &ProviderInfo{
+			AuthType: Oauth2,
+			OauthOpts: OauthOpts{
+				GrantType:                 AuthorizationCode,
+				AuthURL:                   "https://accounts.google.com/o/oauth2/v2/auth",
+				TokenURL:                  "https://oauth2.googleapis.com/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://gmail.googleapis.com",
+		},
+		expectedErr: nil,
+	},
+	{
+		provider:    Monday,
+		description: "Valid Monday provider config with no substitutions",
+		expected: &ProviderInfo{
+			AuthType: Oauth2,
+			OauthOpts: OauthOpts{
+				GrantType:                 AuthorizationCode,
+				AuthURL:                   "https://auth.monday.com/oauth2/authorize",
+				TokenURL:                  "https://auth.monday.com/oauth2/token",
+				ExplicitScopesRequired:    false,
+				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.monday.com",
+		},
+		expectedErr: nil,
+	},
+	{
+		provider:    Figma,
+		description: "Valid Figma provider config with no substitutions",
+		expected: &ProviderInfo{
+			AuthType: Oauth2,
+			OauthOpts: OauthOpts{
+				GrantType:                 AuthorizationCode,
+				AuthURL:                   "https://www.figma.com/oauth",
+				TokenURL:                  "https://www.figma.com/api/oauth/token",
+				ExplicitScopesRequired:    true,
+				ExplicitWorkspaceRequired: false,
+				TokenMetadataFields: TokenMetadataFields{
+					ConsumerRefField: "user_id",
+				},
+			},
+			Support: Support{
+				BulkWrite: BulkWriteSupport{
+					Insert: false,
+					Update: false,
+					Upsert: false,
+					Delete: false,
+				},
+				Proxy:     false,
+				Read:      false,
+				Subscribe: false,
+				Write:     false,
+			},
+			BaseURL: "https://api.figma.com",
+		},
+		expectedErr: nil,
+	},
+	{
+		provider:    Miro,
+		description: "Valid Miro provider config with no substitutions",
+		expected: &ProviderInfo{
+			AuthType: Oauth2,
+			OauthOpts: OauthOpts{
+				GrantType:                 AuthorizationCode,
+				AuthURL:                   "https://miro.com/oauth/authorize",
+				TokenURL:                  "https://api.miro.com/v1/oauth/token",
+				ExplicitScopesRequired:    false,
+				ExplicitWorkspaceRequired: false,
+				TokenMetadataFields: TokenMetadataFields{
+					ConsumerRefField:  "user_id",
+					WorkspaceRefField: "team_id",
+					ScopesField:       "scope",
+				},
+			},
+			Support: Support{
+				BulkWrite: BulkWriteSupport{
+					Insert: false,
+					Update: false,
+					Upsert: false,
+					Delete: false,
+				},
+				Proxy:     true,
+				Read:      false,
+				Subscribe: false,
+				Write:     false,
+			},
+			BaseURL: "https://api.miro.com",
+		},
+		expectedErr: nil,
+	},
+	{
+		provider:    Typeform,
+		description: "Valid Typeform provider config with no substitutions",
+		expected: &ProviderInfo{
+			AuthType: Oauth2,
+			OauthOpts: OauthOpts{
+				GrantType:                 AuthorizationCode,
+				AuthURL:                   "https://api.typeform.com/oauth/authorize",
+				TokenURL:                  "https://api.typeform.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,
+			},
+
+			BaseURL: "https://api.typeform.com",
+		},
+		expectedErr: nil,
+	},
+	{
+		provider:    Zuora,
+		description: "Valid Zuora provider config with substitutions",
+		substitutions: map[string]string{
+			"subdomain": "rest.test",
+		},
+		expected: &ProviderInfo{
+			AuthType: Oauth2,
+			OauthOpts: OauthOpts{
+				GrantType:                 ClientCredentials,
+				AuthURL:                   "https://rest.test.zuora.com/oauth/auth_mock",
+				TokenURL:                  "https://rest.test.zuora.com/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://rest.test.zuora.com",
+		},
+		expectedErr: nil,
+	},
+	{
+		provider:    DropboxSign,
+		description: "Valid DropboxSign provider config with no substitutions",
+		expected: &ProviderInfo{
+			AuthType: Oauth2,
+			OauthOpts: OauthOpts{
+				GrantType:                 AuthorizationCode,
+				AuthURL:                   "https://app.hellosign.com/oauth/authorize",
+				TokenURL:                  "https://app.hellosign.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,
+			},
+
+			BaseURL: "https://api.hellosign.com",
+		},
+		expectedErr: nil,
+	},
 }
 
 func TestReadInfo(t *testing.T) { // nolint
diff --git a/test/microsoftdynamicscrm/README.md b/test/microsoftdynamicscrm/README.md
new file mode 100644
index 00000000..9362eb29
--- /dev/null
+++ b/test/microsoftdynamicscrm/README.md
@@ -0,0 +1,25 @@
+# Running instructions
+
+## Step 1: prepare "ms-dales-creds.json" file
+
+Create a file called "ms-crm-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 MS_CRM_CRED_FILE by following command
+
+$> export MS_CRM_CRED_FILE=./ms-crm-creds.json # or the path to your ms-crm-creds.json file
+
+
+In 1password, you can find a MS CRM creds.json file in the "Shared" vault. TODO this must be in 1password
+Look for the title "MS CRM 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/microsoftdynamicscrm/read/main.go
\ No newline at end of file
diff --git a/test/microsoftdynamicscrm/connector.go b/test/microsoftdynamicscrm/connector.go
new file mode 100644
index 00000000..fd29e0c6
--- /dev/null
+++ b/test/microsoftdynamicscrm/connector.go
@@ -0,0 +1,58 @@
+package microsoftdynamicscrm
+
+import (
+	"context"
+	"net/http"
+
+	"github.com/amp-labs/connectors"
+	"github.com/amp-labs/connectors/microsoftdynamicscrm"
+	testUtils "github.com/amp-labs/connectors/test/utils"
+	"github.com/amp-labs/connectors/utils"
+)
+
+func GetMSDynamics365CRMConnector(ctx context.Context, filePath string) *microsoftdynamicscrm.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.MSDynamics365CRMConfigFromRegistry(registry)
+	tok := utils.MSDynamics365CRMTokenFromRegistry(registry)
+
+	conn, err := connectors.MSDynamicsCRM(
+		microsoftdynamicscrm.WithClient(ctx, http.DefaultClient, cfg, tok),
+		microsoftdynamicscrm.WithWorkspace(utils.MSDynamics365CRMWorkspace),
+		microsoftdynamicscrm.WithModule(microsoftdynamicscrm.DefaultModuleCRM),
+	)
+	if err != nil {
+		testUtils.Fail("error creating microsoft CRM connector", "error", err)
+	}
+
+	return conn
+}
diff --git a/test/microsoftdynamicscrm/metadata/README.md b/test/microsoftdynamicscrm/metadata/README.md
new file mode 100644
index 00000000..4a59ac2a
--- /dev/null
+++ b/test/microsoftdynamicscrm/metadata/README.md
@@ -0,0 +1,14 @@
+# Script Description
+
+Listing object metadata for MS Dynamics 365 CRM will give huge XML document describing schema for entire service.
+A portion is propagated based on the list of desired Objects. This script is concerned about "account" structure.
+In order to validate that metadata is extracted as expected we compare against the response from connector's READ operation.
+
+The test ensures the following rules:
+* Every field returned by `GET ~/accounts` must be in schema description (except annotations)
+* No schema property is omitted
+
+If you would like to validate for other objects, here where you can find more information.
+Under this repo `sortedStructNamesFromSchema.txt` file lists all objects that are available.
+To find out which endpoint can serve these data PROXY call `~/api/data/v9.2/{yourObjectName_plural}`.
+> NOTE: making a GET request to the list resource should have the name pluralised.
diff --git a/test/microsoftdynamicscrm/metadata/main.go b/test/microsoftdynamicscrm/metadata/main.go
new file mode 100644
index 00000000..46f321a5
--- /dev/null
+++ b/test/microsoftdynamicscrm/metadata/main.go
@@ -0,0 +1,100 @@
+package main
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"os"
+	"os/signal"
+	"strings"
+	"syscall"
+	"time"
+
+	"github.com/amp-labs/connectors/common"
+	msTest "github.com/amp-labs/connectors/test/microsoftdynamicscrm"
+	"github.com/amp-labs/connectors/test/utils"
+)
+
+var (
+	objectName       = "account"
+	objectNamePlural = "accounts"
+)
+
+// we want to compare fields returned by read and schema properties provided by metadata methods
+// they must match for all such objects
+func main() {
+	// Handle Ctrl-C gracefully.
+	ctx, done := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+	defer done()
+
+	// Set up slog logging.
+	utils.SetupLogging()
+
+	filePath := os.Getenv("MS_CRM_CRED_FILE")
+	if filePath == "" {
+		filePath = "./ms-crm-creds.json"
+	}
+
+	conn := msTest.GetMSDynamics365CRMConnector(ctx, filePath)
+	defer utils.Close(conn)
+
+	response, err := conn.Read(ctx, common.ReadParams{
+		ObjectName: objectNamePlural,
+	})
+	if err != nil {
+		utils.Fail("error reading from microsoft CRM", "error", err)
+	}
+
+	if response.Rows == 0 {
+		utils.Fail("expected to read at least one record", "error", err)
+	}
+
+	beforeCall := time.Now()
+
+	metadata, err := conn.ListObjectMetadata(ctx, []string{
+		objectName,
+	})
+	if err != nil {
+		utils.Fail("error listing metadata for microsoft CRM", "error", err)
+	}
+
+	fmt.Printf("ListObjectMetadata took %.2f seconds.\n", time.Since(beforeCall).Seconds())
+
+	fmt.Println("Compare object metadata against endpoint response:")
+
+	mismatchErr := compareFieldsMatch(metadata, response.Data[0].Raw, objectName)
+	if mismatchErr != nil {
+		utils.Fail("schema and payload response have mismatching fields", "error", mismatchErr)
+	} else {
+		fmt.Println("==> success fields match.")
+	}
+}
+
+func compareFieldsMatch(metadata *common.ListObjectMetadataResult, response map[string]any, objectName string) error {
+	fields := make(map[string]bool)
+
+	for field := range response {
+		// ignore all fields that are OData annotations
+		if !strings.Contains(field, "@") {
+			fields[field] = false
+		}
+	}
+
+	mismatch := make([]error, 0)
+
+	for _, displayName := range metadata.Result[objectName].FieldsMap {
+		if _, found := fields[displayName]; found {
+			fields[displayName] = true
+		} else {
+			mismatch = append(mismatch, fmt.Errorf("read payload doesn't have %v", displayName))
+		}
+	}
+
+	for name, found := range fields {
+		if !found {
+			mismatch = append(mismatch, fmt.Errorf("metadata schema is missing field %v", name))
+		}
+	}
+
+	return errors.Join(mismatch...)
+}
diff --git a/test/microsoftdynamicscrm/metadata/sortedStructNamesFromSchema.txt b/test/microsoftdynamicscrm/metadata/sortedStructNamesFromSchema.txt
new file mode 100644
index 00000000..4dbdb93e
--- /dev/null
+++ b/test/microsoftdynamicscrm/metadata/sortedStructNamesFromSchema.txt
@@ -0,0 +1,1167 @@
+AttributeMetadata
+BigIntAttributeMetadata
+BooleanAttributeMetadata
+BooleanOptionSetMetadata
+DateTimeAttributeMetadata
+DecimalAttributeMetadata
+DoubleAttributeMetadata
+EntityKeyMetadata
+EntityMetadata
+EntityNameAttributeMetadata
+EnumAttributeMetadata
+FileAttributeMetadata
+ImageAttributeMetadata
+IntegerAttributeMetadata
+LookupAttributeMetadata
+ManagedPropertyAttributeMetadata
+ManagedPropertyMetadata
+ManyToManyRelationshipMetadata
+MemoAttributeMetadata
+MetadataBase
+MoneyAttributeMetadata
+MultiSelectPicklistAttributeMetadata
+OneToManyRelationshipMetadata
+OptionSetMetadata
+OptionSetMetadataBase
+PicklistAttributeMetadata
+RelationshipMetadataBase
+StringAttributeMetadata
+aaduser
+account
+accountleads
+aciviewmapper
+actioncard
+actioncarduserstate
+activityfileattachment
+activitymimeattachment
+activitymonitor
+activityparty
+activitypointer
+adminsettingsentity
+adx_externalidentity
+adx_invitation
+adx_invitation_invitecontacts
+adx_invitation_mspp_webrole_powerpagecomponent
+adx_invitation_redeemedcontacts
+adx_inviteredemption
+adx_kbarticle_kbarticle
+adx_portalcomment
+adx_setting
+adx_webformsession
+aiplugin
+aipluginexternalschema
+aipluginexternalschemaproperty
+aiplugininstance
+aipluginoperation
+aipluginoperationparameter
+aipluginusersetting
+annotation
+annualfiscalcalendar
+appaction
+appaction_appactionrule_classicrules
+appactionmigration
+appactionrule
+appactionrule_webresource_scripts
+appconfig
+appconfiginstance
+appconfigmaster
+applicationuser
+applicationuserprofile
+applicationuserrole
+appmodule
+appmodulecomponent
+appmoduleroles
+appnotification
+appointment
+asyncoperation
+attachment
+attribute
+attributeimageconfig
+attributemaskingrule
+audit
+availabletimes
+availabletimesdatasource
+backgroundoperation
+bookableresource
+bookableresourcebooking
+bookableresourcebookingheader
+bookableresourcecategory
+bookableresourcecategoryassn
+bookableresourcecharacteristic
+bookableresourcegroup
+bookingstatus
+bot
+bot_botcomponent
+bot_botcontentpack
+bot_environmentvariabledefinition
+botcomponent
+botcomponent_aipluginoperation
+botcomponent_botcomponent
+botcomponent_connectionreference
+botcomponent_environmentvariabledefinition
+botcomponent_workflow
+botcontentpack
+bulkdeletefailure
+bulkdeleteoperation
+bulkoperation
+bulkoperationlog
+businessunit
+businessunitnewsarticle
+calendar
+calendarrule
+callbackregistration
+campaign
+campaignactivity
+campaignactivityitem
+campaignitem
+campaignresponse
+canvasapp
+card
+cardentityconnections
+cardstateitem
+catalog
+catalogassignment
+category
+channelaccessprofileentityaccesslevel
+characteristic
+chat
+columnmapping
+competitor
+competitoraddress
+competitorproduct
+competitorsalesliterature
+complexcontrol
+componentversion
+componentversiondatasource
+componentversionnrddatasource
+connection
+connectioninstance
+connectionreference
+connectionrole
+connectionroleassociation
+connector
+constraintbasedgroup
+contact
+contactinvoices
+contactleads
+contactorders
+contactquotes
+contract
+contractdetail
+contracttemplate
+conversationtranscript
+copilotexamplequestion
+credential
+crmbaseentity
+crmmodelbaseentity
+customapi
+customapirequestparameter
+customapiresponseproperty
+customcontrol
+customcontroldefaultconfig
+customcontrolresource
+customeraddress
+datalakefolder
+datalakefolderpermission
+datalakeworkspace
+datalakeworkspacepermission
+dataperformance
+dataprocessingconfiguration
+delegatedauthorization
+dependency
+desktopflowbinary
+desktopflowmodule
+discount
+discounttype
+displaystring
+documenttemplate
+duplicaterecord
+duplicaterule
+duplicaterulecondition
+dvfilesearch
+dvfilesearchattribute
+dvfilesearchentity
+dvtablesearch
+dvtablesearchattribute
+dvtablesearchentity
+dynamicproperty
+dynamicpropertyassociation
+dynamicpropertyinstance
+dynamicpropertyoptionsetitem
+elasticfileattachment
+email
+emailserverprofile
+entitlement
+entitlementchannel
+entitlementcontacts
+entitlemententityallocationtypemapping
+entitlementproducts
+entitlementtemplate
+entitlementtemplatechannel
+entitlementtemplateproducts
+entity
+entityanalyticsconfig
+entitydataprovider
+entityimageconfig
+entityindex
+entitykey
+entityrecordfilter
+entityrelationship
+environmentvariabledefinition
+environmentvariablevalue
+equipment
+eventexpanderbreadcrumb
+exchangesyncidmapping
+expiredprocess
+exportedexcel
+exportsolutionupload
+fax
+featurecontrolsetting
+feedback
+fieldpermission
+fieldsecurityprofile
+fileattachment
+fixedmonthlyfiscalcalendar
+flowcapacityassignment
+flowevent
+flowmachine
+flowmachinegroup
+flowmachineimage
+flowmachineimageversion
+flowmachinenetwork
+flowrun
+flowsession
+fxexpression
+goal
+goalrollupquery
+import
+importdata
+importentitymapping
+importfile
+importjob
+importlog
+importmap
+incident
+incidentknowledgebaserecord
+incidentresolution
+indexattributes
+interactionforemail
+invaliddependency
+invoice
+invoicedetail
+kbarticle
+kbarticlecomment
+kbarticletemplate
+keyvaultreference
+knowledgearticle
+knowledgearticleincident
+knowledgearticlescategories
+knowledgearticleviews
+knowledgebaserecord
+languagelocale
+languageprovisioningstate
+lead
+leadaddress
+leadcompetitors
+leadproduct
+leadtoopportunitysalesprocess
+letter
+list
+listmember
+listoperation
+lookupmapping
+mailbox
+mailboxtrackingfolder
+mailmergetemplate
+mainfewshot
+makerfewshot
+managedidentity
+managedproperty
+marketingformdisplayattributes
+maskingrule
+metadataforarchival
+metric
+mobileofflineprofile
+mobileofflineprofileextension
+mobileofflineprofileitem
+mobileofflineprofileitemassociation
+mobileofflineprofileitemfilter
+monthlyfiscalcalendar
+msdyn_accountkpiitem
+msdyn_actioncardactionstat
+msdyn_actioncardregarding
+msdyn_actioncardrolesetting
+msdyn_actioncardstataggregation
+msdyn_activeicdextension
+msdyn_activityanalysiscleanupstate
+msdyn_activityanalysisconfig
+msdyn_adaptivecardconfiguration
+msdyn_adjustmenthistory
+msdyn_adminappstate
+msdyn_agentcapacityprofileunit
+msdyn_agentcapacityupdatehistory
+msdyn_agentchannelstate
+msdyn_agentcopilotsetting
+msdyn_agentresourceforecasting
+msdyn_agentstatus
+msdyn_agentstatushistory
+msdyn_aibdataset
+msdyn_aibdatasetfile
+msdyn_aibdatasetrecord
+msdyn_aibdatasetscontainer
+msdyn_aibfeedbackloop
+msdyn_aibfile
+msdyn_aibfileattacheddata
+msdyn_aiconfiguration
+msdyn_aicontactsuggestion
+msdyn_aievent
+msdyn_aifptrainingdocument
+msdyn_aimodel
+msdyn_aiodimage
+msdyn_aiodlabel
+msdyn_aiodlabel_msdyn_aiconfiguration
+msdyn_aiodtrainingboundingbox
+msdyn_aiodtrainingimage
+msdyn_aitemplate
+msdyn_analysiscomponent
+msdyn_analysisjob
+msdyn_analysisoverride
+msdyn_analysisresult
+msdyn_analysisresultdetail
+msdyn_analytics
+msdyn_analyticsadminsettings
+msdyn_analyticsforcs
+msdyn_appconfig_msdyn_channelprovider
+msdyn_appconfiguration
+msdyn_appconfiguration_applicationextension
+msdyn_appconfiguration_sessiontemplate
+msdyn_appconfiguration_systemuser
+msdyn_appcopilotconfiguration
+msdyn_appinsightsmetadata
+msdyn_applicationextension
+msdyn_applicationtabtemplate
+msdyn_appprofilerolemapping
+msdyn_appstate
+msdyn_assetcategorytemplateassociation
+msdyn_assettemplateassociation
+msdyn_assignmentconfiguration
+msdyn_assignmentconfigurationstep
+msdyn_assignmentmap
+msdyn_assignmentrule
+msdyn_attribute
+msdyn_attributeinfluencestatistics
+msdyn_attributevalue
+msdyn_authenticationsettings
+msdyn_authsettingsentry
+msdyn_autocapturerule
+msdyn_autocapturesettings
+msdyn_bgjobledger
+msdyn_bookableresourcecapacityprofile
+msdyn_botsession
+msdyn_callablecontext
+msdyn_callablecontext_msdyn_playbooktemplate
+msdyn_cannedmessage
+msdyn_capacityprofile
+msdyn_caseenrichment
+msdyn_casesuggestion
+msdyn_casesuggestionrequestpayload
+msdyn_casesuggestionsdatasource
+msdyn_casetopic
+msdyn_casetopic_incident
+msdyn_casetopicsetting
+msdyn_casetopicsummary
+msdyn_cdsentityengagementctx
+msdyn_channelcapability
+msdyn_channeldefinition
+msdyn_channeldefinitionconsent
+msdyn_channeldefinitionlocale
+msdyn_channelinstance
+msdyn_channelinstanceaccount
+msdyn_channelmessageattachment
+msdyn_channelmessagecontextpart
+msdyn_channelmessagepart
+msdyn_channelprovider
+msdyn_ciprovider
+msdyn_ciprovider_systemuser
+msdyn_collabgraphresource
+msdyn_collabspaceteamassociation
+msdyn_componentlayer
+msdyn_componentlayerdatasource
+msdyn_connectordatasource
+msdyn_consoleapplicationnotificationfield
+msdyn_consoleapplicationnotificationtemplate
+msdyn_consoleapplicationsessiontemplate
+msdyn_consoleapplicationtemplate
+msdyn_consoleapplicationtemplateparameter
+msdyn_consoleapplicationtype
+msdyn_consoleappparameterdefinition
+msdyn_consumingapplication
+msdyn_contactkpiitem
+msdyn_contactsuggestionrule
+msdyn_contactsuggestionruleset
+msdyn_conversationaction
+msdyn_conversationactionitem
+msdyn_conversationactionlocale
+msdyn_conversationaggregatedinsights
+msdyn_conversationcomment
+msdyn_conversationdata
+msdyn_conversationinsight
+msdyn_conversationmessageblock
+msdyn_conversationparticipantinsights
+msdyn_conversationparticipantsentiment
+msdyn_conversationquestion
+msdyn_conversationsegmentsentiment
+msdyn_conversationsentiment
+msdyn_conversationsignal
+msdyn_conversationsubject
+msdyn_conversationsuggestionrequestpayload
+msdyn_conversationsummaryinteraction
+msdyn_conversationsummarysetting
+msdyn_conversationsummarysuggestion
+msdyn_conversationsystemtag
+msdyn_conversationtag
+msdyn_conversationtopic
+msdyn_conversationtopic_conversation
+msdyn_conversationtopicsetting
+msdyn_conversationtopicsummary
+msdyn_copilotagentpreference
+msdyn_copilotinteraction
+msdyn_copilotinteractiondata
+msdyn_copilotsummarizationsetting
+msdyn_copilottranscript
+msdyn_copilottranscriptdata
+msdyn_crmconnection
+msdyn_csadminconfig
+msdyn_cskeyvalueconfig
+msdyn_customapirulesetconfiguration
+msdyn_customcontrolextendedsettings
+msdyn_customerasset
+msdyn_customerassetattachment
+msdyn_customerassetcategory
+msdyn_customeremailcommunication
+msdyn_dailyaccountkpiitem
+msdyn_dailycontactkpiitem
+msdyn_dailyleadkpiitem
+msdyn_dailyopportunitykpiitem
+msdyn_dataanalyticscustomizedreport
+msdyn_dataanalyticsdataset
+msdyn_dataanalyticsreport
+msdyn_dataanalyticsreport_csrmanager
+msdyn_dataanalyticsreport_forecast
+msdyn_dataanalyticsreport_ksinsights
+msdyn_dataanalyticsreport_oc
+msdyn_dataanalyticsreport_oc_rt
+msdyn_dataanalyticsreport_ocvoice
+msdyn_dataanalyticsreport_sareporting
+msdyn_dataanalyticsreport_sutreporting
+msdyn_dataanalyticsworkspace
+msdyn_databaseversion
+msdyn_dataflow
+msdyn_dataflow_datalakefolder
+msdyn_dataflowconnectionreference
+msdyn_dataflowrefreshhistory
+msdyn_dataflowtemplate
+msdyn_datahygienesettinginfo
+msdyn_datainsightsandanalyticsfeature
+msdyn_datalakeds
+msdyn_dealmanageraccess
+msdyn_dealmanagersettings
+msdyn_decisioncontract
+msdyn_decisionruleset
+msdyn_defextendedchannelinstance
+msdyn_defextendedchannelinstanceaccount
+msdyn_deletedconversation
+msdyn_digitalsellingactivetask
+msdyn_digitalsellingcompletedtask
+msdyn_distributedlock
+msdyn_dmsrequest
+msdyn_dmsrequeststatus
+msdyn_dmssyncrequest
+msdyn_dmssyncstatus
+msdyn_duplicatedetectionpluginrun
+msdyn_duplicateleadmapping
+msdyn_effortpredictionresult
+msdyn_entityattachment
+msdyn_entityconfig
+msdyn_entitydeltachange
+msdyn_entitylinkchatconfiguration
+msdyn_entityrankingrule
+msdyn_entityrefreshhistory
+msdyn_entityroutingconfiguration
+msdyn_entityworkstreammap
+msdyn_extendedusersetting
+msdyn_favoriteknowledgearticle
+msdyn_federatedarticle
+msdyn_federatedarticleincident
+msdyn_fileupload
+msdyn_fileuploadstatustracker
+msdyn_flowcardtype
+msdyn_forecast
+msdyn_forecastconfiguration
+msdyn_forecastdefinition
+msdyn_forecastingcache
+msdyn_forecastinsight
+msdyn_forecastinstance
+msdyn_forecastpredictiondata
+msdyn_forecastpredictionstatus
+msdyn_forecastrecurrence
+msdyn_forecastsettingsandsummary
+msdyn_functionallocation
+msdyn_gdprdata
+msdyn_helppage
+msdyn_icdextension
+msdyn_icebreakersconfig
+msdyn_iermlmodel
+msdyn_iermltraining
+msdyn_inboxcardconfiguration
+msdyn_inboxconfiguration
+msdyn_inboxentityconfig
+msdyn_incident_msdyn_customerasset
+msdyn_insightsstorevirtualentity
+msdyn_integratedsearchprovider
+msdyn_intent
+msdyn_iotalert
+msdyn_iotdevice
+msdyn_iotdevicecategory
+msdyn_iotdevicecategorycommands
+msdyn_iotdevicecommand
+msdyn_iotdevicecommanddefinition
+msdyn_iotdevicecommandparameters
+msdyn_iotdevicedatahistory
+msdyn_iotdeviceproperty
+msdyn_iotdeviceregistrationhistory
+msdyn_iotdevicevisualizationconfiguration
+msdyn_iotfieldmapping
+msdyn_iotpropertydefinition
+msdyn_iotprovider
+msdyn_iotproviderinstance
+msdyn_iotsettings
+msdyn_iottocaseprocess
+msdyn_jobsstate
+msdyn_kalanguagesetting
+msdyn_kbattachment
+msdyn_kbenrichment
+msdyn_kbkeywordsdescsuggestionsetting
+msdyn_kmfederatedsearchconfig
+msdyn_kmpersonalizationsetting
+msdyn_knowledgearticleimage
+msdyn_knowledgearticlesuggestion
+msdyn_knowledgearticlesuggestiondatasource
+msdyn_knowledgearticletemplate
+msdyn_knowledgeconfiguration
+msdyn_knowledgeinteractioninsight
+msdyn_knowledgemanagementsetting
+msdyn_knowledgepersonalfilter
+msdyn_knowledgesearchfilter
+msdyn_knowledgesearchinsight
+msdyn_kpieventdata
+msdyn_kpieventdefinition
+msdyn_leadhygienesetting
+msdyn_leadkpiitem
+msdyn_leadmodelconfig
+msdyn_linkedentityattributevalidity
+msdyn_liveconversation
+msdyn_liveworkitemevent
+msdyn_liveworkstream
+msdyn_liveworkstreamcapacityprofile
+msdyn_macrosession
+msdyn_maskingrule
+msdyn_masterentityroutingconfiguration
+msdyn_migrationtracker
+msdyn_mobileapp
+msdyn_modelpreviewstatus
+msdyn_modulerundetail
+msdyn_mostcontacted
+msdyn_mostcontactedby
+msdyn_msdyn_attributevalue_systemuser
+msdyn_msdyn_cannedmessage_liveworkstream
+msdyn_msdyn_cannedmessage_msdyn_octag
+msdyn_msdyn_consoleapplicationnotificationtag
+msdyn_msdyn_consoleapplicationnotificationtem
+msdyn_msdyn_consoleapplicationsessiontemp_tag
+msdyn_msdyn_consoleapplicationsessiontemplate
+msdyn_msdyn_consoleapplicationtemplate_msdyn_
+msdyn_msdyn_consoleapplicationtemplate_tags
+msdyn_msdyn_consoleapplicationtype_msdyn_cons
+msdyn_msdyn_customerasset_knowledgearticl
+msdyn_msdyn_customerasset_knowledgebasere
+msdyn_msdyn_functionallocation_account
+msdyn_msdyn_kbattachment_knowledgearticle
+msdyn_msdyn_liveworkstream_systemuser
+msdyn_msdyn_ocliveworkitem_knowledgeartic
+msdyn_msdyn_ocliveworkitem_knowledgebaser
+msdyn_msdyn_ocrichobject_liveworkstream
+msdyn_msdyn_ocrichobject_msdyn_octag
+msdyn_msdyn_paneconfig_msdyn_appconfig
+msdyn_msdyn_personalmessage_msdyn_octag
+msdyn_msdyn_prod_actioninputparameter_msdyn_par
+msdyn_msdyn_prod_actionoutputparameter_msdyn_pa
+msdyn_msdyn_prod_agentscript_msdyn_sessiontempl
+msdyn_msteamssetting
+msdyn_msteamssettingsv2
+msdyn_nonrelationalds
+msdyn_notesanalysisconfig
+msdyn_notificationfield
+msdyn_notificationtemplate
+msdyn_notificationtemplate_notificationfield
+msdyn_oc_geolocationprovider
+msdyn_ocautoblockrule
+msdyn_ocbotchannelregistration
+msdyn_ocbotchannelregistrationsecret
+msdyn_occhannelapiconversationprivilege
+msdyn_occhannelapimessageprivilege
+msdyn_occhannelapimethodmapping
+msdyn_occhannelconfiguration
+msdyn_occhannelstateconfiguration
+msdyn_ocexternalcontext
+msdyn_ocflaggedspam
+msdyn_oclanguage
+msdyn_ocliveworkitem
+msdyn_ocliveworkitemcapacityprofile
+msdyn_ocliveworkitemcharacteristic
+msdyn_ocliveworkitemcontextitem
+msdyn_ocliveworkitemparticipant
+msdyn_ocliveworkitemsentiment
+msdyn_ocliveworkstreamcontextvariable
+msdyn_oclocalizationdata
+msdyn_ocpaymentprofile
+msdyn_ocprovisioningstate
+msdyn_ocrecording
+msdyn_ocrequest
+msdyn_ocrichobject
+msdyn_ocrichobjectmap
+msdyn_ocruleitem
+msdyn_ocsentimentdailytopic
+msdyn_ocsentimentdailytopickeyword
+msdyn_ocsentimentdailytopictrending
+msdyn_ocsession
+msdyn_ocsessioncharacteristic
+msdyn_ocsessionparticipantevent
+msdyn_ocsessionsentiment
+msdyn_ocsimltraining
+msdyn_ocsitdimportconfig
+msdyn_ocsitdskill
+msdyn_ocsitrainingdata
+msdyn_ocskillidentmlmodel
+msdyn_ocsystemmessage
+msdyn_octag
+msdyn_odatav4ds
+msdyn_odosfeaturemetadata
+msdyn_odosmetadata
+msdyn_omnichannelconfiguration
+msdyn_omnichannelpersonalization
+msdyn_omnichannelqueue
+msdyn_omnichannelsyncconfig
+msdyn_operatinghour
+msdyn_opportunitykpiitem
+msdyn_opportunitymodelconfig
+msdyn_orgchartnode
+msdyn_originatingqueue
+msdyn_overflowactionconfig
+msdyn_paneconfiguration
+msdyn_panetabconfiguration
+msdyn_panetoolconfiguration
+msdyn_personalmessage
+msdyn_personalsoundsetting
+msdyn_personasecurityrolemapping
+msdyn_playbookactivity
+msdyn_playbookactivityattribute
+msdyn_playbookcategory
+msdyn_playbookinstance
+msdyn_playbooktemplate
+msdyn_pmanalysishistory
+msdyn_pmbusinessruleautomationconfig
+msdyn_pmcalendar
+msdyn_pmcalendarversion
+msdyn_pminferredtask
+msdyn_pmprocessextendedmetadataversion
+msdyn_pmprocesstemplate
+msdyn_pmprocessusersettings
+msdyn_pmprocessversion
+msdyn_pmrecording
+msdyn_pmtemplate
+msdyn_pmview
+msdyn_postalbum
+msdyn_postconfig
+msdyn_postruleconfig
+msdyn_predictioncomputationoperation
+msdyn_predictionmodelstatus
+msdyn_predictionscheduledoperation
+msdyn_predictivemodelscore
+msdyn_predictivescore
+msdyn_predictivescoringsyncstatus
+msdyn_preferredagent
+msdyn_preferredagentcustomeridentity
+msdyn_preferredagentroutedentity
+msdyn_presence
+msdyn_product_knowledgearticle
+msdyn_product_knowledgebaserecord
+msdyn_productivityactioninputparameter
+msdyn_productivityactionoutputparameter
+msdyn_productivityagentscript
+msdyn_productivityagentscriptstep
+msdyn_productivitymacroactiontemplate
+msdyn_productivitymacroconnector
+msdyn_productivitymacrosolutionconfiguration
+msdyn_productivityparameterdefinition
+msdyn_property
+msdyn_propertyassetassociation
+msdyn_propertylog
+msdyn_propertytemplateassociation
+msdyn_provider
+msdyn_readtracker
+msdyn_readtrackingenabledinfo
+msdyn_realtimescoring
+msdyn_realtimescoringoperation
+msdyn_recomputetracker
+msdyn_recording
+msdyn_recurrence
+msdyn_recurringsalesaction
+msdyn_recurringsalesactionv2
+msdyn_relationshipanalyticsmetadata
+msdyn_relationshipinsightsunifiedconfig
+msdyn_reportbookmark
+msdyn_richtextfile
+msdyn_routingconfiguration
+msdyn_routingconfigurationstep
+msdyn_routingrequest
+msdyn_routingrulesetsetting
+msdyn_rulesetdependencymapping
+msdyn_sabackupdiagnostic
+msdyn_sabatchruninstance
+msdyn_salesaccelerationinsight
+msdyn_salesaccelerationsettings
+msdyn_salesassignmentsetting
+msdyn_salescopilotinsight
+msdyn_salescopilotorgsettings
+msdyn_salesinsightssettings
+msdyn_salesocmessage
+msdyn_salesocsmstemplate
+msdyn_salespremiumdemo
+msdyn_salesroutingdiagnostic
+msdyn_salesroutingrun
+msdyn_salessuggestion
+msdyn_salestag
+msdyn_salestag_msdyn_sequence
+msdyn_saruninstance
+msdyn_schedule
+msdyn_sciconversation
+msdyn_scicustomemailhighlight
+msdyn_scicustomhighlight
+msdyn_scicustompublisher
+msdyn_scienvironmentsettings
+msdyn_sciusersettings
+msdyn_searchconfiguration
+msdyn_segment
+msdyn_segmentationsetting
+msdyn_segmentattribute
+msdyn_segmentcatalogue
+msdyn_sentimentanalysis
+msdyn_sequence
+msdyn_sequencestat
+msdyn_sequencetarget
+msdyn_sequencetargetstep
+msdyn_sequencetemplate
+msdyn_serviceconfiguration
+msdyn_servicecopilotplugin
+msdyn_servicecopilotpluginrole
+msdyn_serviceoneprovisioningrequest
+msdyn_sessiondata
+msdyn_sessionevent
+msdyn_sessionparticipant
+msdyn_sessionparticipantdata
+msdyn_sessiontemplate
+msdyn_sessiontemplate_applicationtab
+msdyn_shareasconfiguration
+msdyn_siconfig
+msdyn_sikeyvalueconfig
+msdyn_similarentitiesfeatureimportance
+msdyn_skillattachmentruleitem
+msdyn_skillattachmenttarget
+msdyn_slakpi
+msdyn_smartassistconfig
+msdyn_smartassistconfig_msdyn_appconfig
+msdyn_solutioncomponentcountdatasource
+msdyn_solutioncomponentcountsummary
+msdyn_solutioncomponentdatasource
+msdyn_solutioncomponentsummary
+msdyn_solutionhealthrule
+msdyn_solutionhealthruleargument
+msdyn_solutionhealthruleset
+msdyn_solutionhistory
+msdyn_solutionhistorydatasource
+msdyn_soundfile
+msdyn_soundnotificationsetting
+msdyn_submodeldefinition
+msdyn_suggestedactivity
+msdyn_suggestedactivitydatasource
+msdyn_suggestedcontact
+msdyn_suggestedcontactsdatasource
+msdyn_suggestionassignmentrule
+msdyn_suggestioninteraction
+msdyn_suggestionprincipalobjectaccess
+msdyn_suggestionrequestpayload
+msdyn_suggestionsellerpriority
+msdyn_suggestionsmodelsummary
+msdyn_suggestionssetting
+msdyn_swarm
+msdyn_swarmparticipant
+msdyn_swarmparticipantrule
+msdyn_swarmrole
+msdyn_swarmskill
+msdyn_swarmtemplate
+msdyn_systemuser_msdyn_omnichannelqueue
+msdyn_taggedrecord
+msdyn_teamschatassociation
+msdyn_teamschatsuggestion
+msdyn_teamscollaboration
+msdyn_teamsdialeradminsettings
+msdyn_templateforproperties
+msdyn_templateparameter
+msdyn_templatetags
+msdyn_timelinepin
+msdyn_timespent
+msdyn_tour
+msdyn_trainingresult
+msdyn_transcript
+msdyn_unifiedroutingdiagnostic
+msdyn_unifiedroutingrun
+msdyn_unifiedroutingsetuptracker
+msdyn_untrackedappointment
+msdyn_upgraderun
+msdyn_upgradestep
+msdyn_upgradeversion
+msdyn_urnotificationtemplate
+msdyn_urnotificationtemplatemapping
+msdyn_usagemetric
+msdyn_usagereporting
+msdyn_usersetting
+msdyn_virtualtablecolumncandidate
+msdyn_visitorjourney
+msdyn_vivacustomerlist
+msdyn_vivaentitysetting
+msdyn_vivaorgextensioncred
+msdyn_vivaorgsetting
+msdyn_vivausersetting
+msdyn_wallsavedquery
+msdyn_wallsavedqueryusersettings
+msdyn_wkwcolleaguesforcompany
+msdyn_wkwcolleaguesforcontact
+msdyn_wkwconfig
+msdyn_workflowactionstatus
+msdyn_worklistsuggestion
+msdyn_worklistsuggestionsource
+msdyn_worklistviewconfiguration
+msdyn_workqueuerecord
+msdyn_workqueuestate
+msdyn_workqueueusersetting
+msdyn_wqdatasource
+msdynce_botcontent
+msdyncrm_addtocalendarstyle
+msdyncrm_basestyle
+msdyncrm_buttonstyle
+msdyncrm_codestyle
+msdyncrm_columnstyle
+msdyncrm_contentblockstyle
+msdyncrm_dividerstyle
+msdyncrm_generalstyles
+msdyncrm_imagestyle
+msdyncrm_layoutstyle
+msdyncrm_qrcodestyle
+msdyncrm_textstyle
+msdyncrm_videostyle
+msdynmkt_byoacschannelinstance
+msdynmkt_byoacschannelinstanceaccount
+msdynmkt_catalogeventstatusconfiguration
+msdynmkt_configuration
+msdynmkt_eventmetadata
+msdynmkt_eventmetadata_sdkmessageprocessingstep
+msdynmkt_eventparametermetadata
+msdynmkt_experimentv2
+msdynmkt_featureconfiguration
+msdynmkt_infobipchannelinstance
+msdynmkt_infobipchannelinstanceaccount
+msdynmkt_linkmobilitychannelinstance
+msdynmkt_linkmobilitychannelinstanceaccount
+msdynmkt_metadataentityrelationship
+msdynmkt_metadataitem
+msdynmkt_metadatastorestate
+msdynmkt_mocksmsproviderchannelinstance
+msdynmkt_mocksmsproviderchannelinstanceaccount
+msdynmkt_predefinedplaceholder
+msdynmkt_telesignchannelinstance
+msdynmkt_telesignchannelinstanceaccount
+msdynmkt_trackingcontext
+msdynmkt_twiliochannelinstance
+msdynmkt_twiliochannelinstanceaccount
+msdynmkt_vibeschannelinstance
+msdynmkt_vibeschannelinstanceaccount
+msfp_alert
+msfp_alertrule
+msfp_emailtemplate
+msfp_fileresponse
+msfp_localizedemailtemplate
+msfp_project
+msfp_question
+msfp_questionresponse
+msfp_satisfactionmetric
+msfp_survey
+msfp_surveyinvite
+msfp_surveyreminder
+msfp_surveyresponse
+msfp_unsubscribedrecipient
+msgraphresourcetosubscription
+mspcat_catalogsubmissionfiles
+mspcat_packagestore
+mspp_accesscontrolrule_publishingstate
+mspp_adplacement
+mspp_columnpermission
+mspp_columnpermissionprofile
+mspp_columnpermissionprofile_webrole
+mspp_contentsnippet
+mspp_entityform
+mspp_entityformmetadata
+mspp_entitylist
+mspp_entitypermission
+mspp_entitypermission_webrole
+mspp_pagetemplate
+mspp_pollplacement
+mspp_powerpagescoreentityds
+mspp_publishingstate
+mspp_publishingstatetransitionrule
+mspp_publishingstatetransitionrule_webrole
+mspp_redirect
+mspp_shortcut
+mspp_sitemarker
+mspp_sitesetting
+mspp_webfile
+mspp_webform
+mspp_webformmetadata
+mspp_webformstep
+mspp_weblink
+mspp_weblinkset
+mspp_webpage
+mspp_webpageaccesscontrolrule
+mspp_webpageaccesscontrolrule_webrole
+mspp_webrole
+mspp_website
+mspp_websiteaccess
+mspp_websiteaccess_webrole
+mspp_websitelanguage
+mspp_webtemplate
+navigationsetting
+newprocess
+nlsqregistration
+officegraphdocument
+opportunity
+opportunityclose
+opportunitycompetitors
+opportunityproduct
+opportunitysalesprocess
+optionset
+orderclose
+organization
+organizationdatasyncfnostate
+organizationdatasyncstate
+organizationdatasyncsubscription
+organizationdatasyncsubscriptionentity
+organizationdatasyncsubscriptionfnotable
+ownermapping
+package
+package_solution
+packagehistory
+personaldocumenttemplate
+phonecall
+phonetocaseprocess
+picklistmapping
+pluginassembly
+pluginpackage
+plugintracelog
+plugintype
+plugintypestatistic
+position
+post
+postcomment
+postfollow
+postlike
+postregarding
+powerbidataset
+powerbidatasetapdx
+powerbimashupparameter
+powerbireport
+powerbireportapdx
+powerfxrule
+powerpagecomponent
+powerpagecomponent_mspp_webrole_account
+powerpagecomponent_mspp_webrole_contact
+powerpagecomponent_powerpagecomponent
+powerpagecomponent_webrole_systemuser
+powerpagesite
+powerpagesitelanguage
+powerpagesitepublished
+pricelevel
+principal
+principalentitybusinessunitmap
+principalentitymap
+principalobjectaccess
+principalobjectattributeaccess
+privilege
+privilegesremovalsetting
+processsession
+processstage
+processstageparameter
+processtrigger
+product
+productassociation
+productpricelevel
+productsalesliterature
+productsubstitute
+provisionlanguageforuser
+publisher
+publisheraddress
+quarterlyfiscalcalendar
+queue
+queueitem
+queuemembership
+quote
+quoteclose
+quotedetail
+ratingmodel
+ratingvalue
+recentlyused
+recommendeddocument
+recordfilter
+recurrencerule
+recurringappointmentmaster
+recyclebinconfig
+relationship
+relationshipattribute
+report
+reportcategory
+resource
+resourcegroup
+resourcegroupdatasource
+resourcespec
+retaineddataexcel
+retentionconfig
+retentionfailuredetail
+retentionoperation
+retentionoperationdetail
+ribbonmetadatatoprocess
+role
+roleeditorlayout
+roleprivileges
+roletemplate
+roletemplateprivileges
+rollupfield
+runtimedependency
+salesliterature
+salesliteratureitem
+salesorder
+salesorderdetail
+savedquery
+savedqueryvisualization
+sdkmessage
+sdkmessagefilter
+sdkmessageprocessingstep
+sdkmessageprocessingstepimage
+sdkmessageprocessingstepsecureconfig
+searchattributesettings
+searchcustomanalyzer
+searchrelationshipsettings
+searchtelemetry
+semiannualfiscalcalendar
+service
+serviceappointment
+servicecontractcontacts
+serviceendpoint
+serviceplan
+serviceplanappmodules
+serviceplancustomcontrol
+serviceplanmapping
+sharedlinksetting
+sharedobject
+sharedworkspace
+sharedworkspaceaccesstoken
+sharedworkspacepool
+sharepointdocumentlocation
+sharepointsite
+similarityrule
+site
+sitemap
+sla
+slaitem
+slakpiinstance
+socialactivity
+socialprofile
+solution
+solutioncomponent
+solutioncomponentattributeconfiguration
+solutioncomponentbatchconfiguration
+solutioncomponentconfiguration
+solutioncomponentrelationshipconfiguration
+solutionhistorydata
+stagedentity
+stagedentityattribute
+stagedmetadataasyncoperation
+stagesolutionupload
+subject
+subscriptionmanuallytrackedobject
+subscriptionstatisticsoffline
+subscriptionstatisticsoutlook
+subscriptionsyncentryoffline
+subscriptionsyncentryoutlook
+supportusertable
+synapsedatabase
+synapselinkexternaltablestate
+synapselinkprofile
+synapselinkprofileentity
+synapselinkprofileentitystate
+synapselinkschedule
+syncerror
+systemform
+systemuser
+systemuserauthorizationchangetracker
+systemuserlicenses
+systemuserprofiles
+systemuserroles
+systemusersyncmappingprofiles
+task
+team
+teammembership
+teammobileofflineprofilemembership
+teamprofiles
+teamroles
+teamsyncattributemappingprofiles
+teamtemplate
+template
+territory
+theme
+timestampdatemapping
+timezonedefinition
+timezonelocalizedname
+timezonerule
+tracelog
+transactioncurrency
+transformationmapping
+transformationparametermapping
+translationprocess
+uom
+uomschedule
+userform
+usermapping
+usermobileofflineprofilemembership
+userquery
+userqueryvisualization
+userrating
+usersettings
+virtualentitymetadata
+virtualresourcegroupresource
+webresource
+webwizard
+workflow
+workflowbinary
+workflowcardconnections
+workflowlog
+workqueue
+workqueueitem
diff --git a/test/microsoftdynamicscrm/read/main.go b/test/microsoftdynamicscrm/read/main.go
new file mode 100644
index 00000000..7bc022ff
--- /dev/null
+++ b/test/microsoftdynamicscrm/read/main.go
@@ -0,0 +1,48 @@
+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/microsoftdynamicscrm"
+	"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("MS_CRM_CRED_FILE")
+	if filePath == "" {
+		filePath = "./ms-crm-creds.json"
+	}
+
+	conn := msTest.GetMSDynamics365CRMConnector(ctx, filePath)
+	defer utils.Close(conn)
+
+	res, err := conn.Read(ctx, common.ReadParams{
+		ObjectName: "contacts",
+		Fields: []string{
+			"fullname", "emailaddress1", "fax",
+		},
+	})
+	if err != nil {
+		utils.Fail("error reading from microsoft CRM", "error", err)
+	}
+
+	fmt.Println("Reading contacts..")
+	utils.DumpJSON(res, os.Stdout)
+
+	if res.Rows > microsoftdynamicscrm.DefaultPageSize {
+		utils.Fail(fmt.Sprintf("expected max %v rows", microsoftdynamicscrm.DefaultPageSize))
+	}
+}
diff --git a/test/microsoftdynamicscrm/write-delete/main.go b/test/microsoftdynamicscrm/write-delete/main.go
new file mode 100644
index 00000000..c635a649
--- /dev/null
+++ b/test/microsoftdynamicscrm/write-delete/main.go
@@ -0,0 +1,169 @@
+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/microsoftdynamicscrm"
+	"github.com/amp-labs/connectors/test/utils"
+)
+
+type LeadCreatePayload struct {
+	LastName    string `json:"lastname,omitempty"`
+	FirstName   string `json:"firstname,omitempty"`
+	CompanyName string `json:"companyname,omitempty"`
+	Subject     string `json:"subject,omitempty"`
+}
+
+type LeadUploadPayload struct {
+	LastName    *string `json:"lastname,omitempty"`
+	FirstName   *string `json:"firstname,omitempty"`
+	CompanyName *string `json:"companyname,omitempty"`
+	Subject     *string `json:"subject,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("MS_CRM_CRED_FILE")
+	if filePath == "" {
+		filePath = "./ms-crn-creds.json"
+	}
+
+	conn := msTest.GetMSDynamics365CRMConnector(ctx, filePath)
+	defer utils.Close(conn)
+
+	fmt.Println("> TEST Create/Update/Delete lead")
+	fmt.Println("Creating lead")
+	createLead(ctx, conn, &LeadCreatePayload{
+		LastName:    "Sponge",
+		FirstName:   "Bob",
+		CompanyName: "Bikini Bottom",
+		Subject:     "Burgers",
+	})
+
+	fmt.Println("Reading leads")
+
+	res := readLeads(ctx, conn)
+
+	fmt.Println("Finding recently created lead")
+
+	lead := searchLead(res, "subject", "Burgers")
+	leadID := fmt.Sprintf("%v", lead["leadid"])
+	fmt.Println("Updating some lead properties")
+	updateLead(ctx, conn, leadID, &LeadUploadPayload{
+		LastName:  strPtr(""),
+		FirstName: strPtr("Squidward"),
+	})
+	fmt.Println("View that lead has changed accordingly")
+
+	res = readLeads(ctx, conn)
+
+	lead = searchLead(res, "leadid", leadID)
+	for k, v := range map[string]string{
+		"lastname":    "",
+		"firstname":   "Squidward",
+		"companyname": "Bikini Bottom",
+		"subject":     "Burgers",
+	} {
+		if !compare(lead[k], v) {
+			utils.Fail("error updated properties do not match", k, v, lead[k])
+		}
+	}
+
+	fmt.Println("Removing this lead")
+	removeLead(ctx, conn, leadID)
+	fmt.Println("> Successful test completion")
+}
+
+func searchLead(res *common.ReadResult, key, value string) map[string]any {
+	for _, data := range res.Data {
+		if data.Fields[key] == value {
+			return data.Fields
+		}
+	}
+
+	utils.Fail("error finding lead")
+
+	return nil
+}
+
+func readLeads(ctx context.Context, conn *microsoftdynamicscrm.Connector) *common.ReadResult {
+	res, err := conn.Read(ctx, common.ReadParams{
+		ObjectName: "leads",
+		Fields: []string{
+			"leadid", "lastname", "firstname", "companyname", "subject",
+		},
+	})
+	if err != nil {
+		utils.Fail("error reading from microsoft CRM", "error", err)
+	}
+
+	return res
+}
+
+func createLead(ctx context.Context, conn *microsoftdynamicscrm.Connector, payload *LeadCreatePayload) {
+	res, err := conn.Write(ctx, common.WriteParams{
+		ObjectName: "leads",
+		RecordId:   "",
+		RecordData: payload,
+	})
+	if err != nil {
+		utils.Fail("error writing to microsoft CRM", "error", err)
+	}
+
+	if !res.Success {
+		utils.Fail("failed to create a lead")
+	}
+}
+
+func updateLead(ctx context.Context, conn *microsoftdynamicscrm.Connector, leadID string, payload *LeadUploadPayload) {
+	res, err := conn.Write(ctx, common.WriteParams{
+		ObjectName: "leads",
+		RecordId:   leadID,
+		RecordData: payload,
+	})
+	if err != nil {
+		utils.Fail("error writing to microsoft CRM", "error", err)
+	}
+
+	if !res.Success {
+		utils.Fail("failed to update a lead")
+	}
+}
+
+func removeLead(ctx context.Context, conn *microsoftdynamicscrm.Connector, leadID string) {
+	res, err := conn.Delete(ctx, common.DeleteParams{
+		ObjectName: "leads",
+		RecordId:   leadID,
+	})
+	if err != nil {
+		utils.Fail("error deleting for microsoft CRM", "error", err)
+	}
+
+	if !res.Success {
+		utils.Fail("failed to remove a lead")
+	}
+}
+
+func strPtr(s string) *string {
+	return &s
+}
+
+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/outreach/read/read.go b/test/outreach/read/read.go
new file mode 100644
index 00000000..0dc8021d
--- /dev/null
+++ b/test/outreach/read/read.go
@@ -0,0 +1,86 @@
+package main
+
+import (
+	"context"
+	"encoding/json"
+	"log"
+	"net/http"
+	"os"
+
+	"github.com/amp-labs/connectors"
+	"github.com/amp-labs/connectors/outreach"
+	testUtils "github.com/amp-labs/connectors/test/utils"
+	"github.com/amp-labs/connectors/utils"
+)
+
+const (
+	DefaultCredsFile = "creds.json"
+)
+
+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() {
+	outreach := GetOutreachConnector(context.Background(), DefaultCredsFile)
+
+	config := connectors.ReadParams{
+		ObjectName: "users",
+		// NextPage:   "https://api.outreach.io/api/v2/users?page%5Blimit%5D=1\u0026page%5Boffset%5D=2",
+	}
+
+	result, err := outreach.Read(context.Background(), config)
+	if err != nil {
+		log.Fatal(err)
+	}
+
+	// Print the results
+	jsonStr, err := json.MarshalIndent(result, "", "  ")
+	if err != nil {
+		log.Fatal(err)
+	}
+
+	_, _ = os.Stdout.Write(jsonStr)
+	_, _ = os.Stdout.WriteString("\n")
+}
diff --git a/test/salesforce/read-write/read-write.go b/test/salesforce/read-write/read-write.go
index a8aaca0f..54201965 100644
--- a/test/salesforce/read-write/read-write.go
+++ b/test/salesforce/read-write/read-write.go
@@ -66,7 +66,13 @@ func mainFn() int { //nolint:funlen
 		_ = sfc.Close()
 	}()
 
-	if err := testConnector(ctx, sfc); err != nil {
+	if err := testReadConnector(ctx, sfc); err != nil {
+		slog.Error("Error testing", "connector", sfc, "error", err)
+
+		return 1
+	}
+
+	if err := testWriteConnector(ctx, sfc); err != nil {
 		slog.Error("Error testing", "connector", sfc, "error", err)
 
 		return 1
@@ -75,7 +81,7 @@ func mainFn() int { //nolint:funlen
 	return 0
 }
 
-func testConnector(ctx context.Context, conn connectors.Connector) error {
+func testReadConnector(ctx context.Context, conn connectors.ReadConnector) error {
 	// Create a context with a timeout
 	ctx, done := context.WithTimeout(ctx, TimeoutSeconds*time.Second)
 	defer done()
@@ -98,6 +104,10 @@ func testConnector(ctx context.Context, conn connectors.Connector) error {
 	_, _ = os.Stdout.Write(jsonStr)
 	_, _ = os.Stdout.WriteString("\n")
 
+	return nil
+}
+
+func testWriteConnector(ctx context.Context, conn connectors.WriteConnector) error {
 	// IMPORTANT: every time this test is run, it will create a new Account
 	// in SFDC instance. Will need to delete those out at later date.
 	writtenRecordId, err := testSalesforceValidCreate(ctx, conn)
@@ -115,7 +125,7 @@ func testConnector(ctx context.Context, conn connectors.Connector) error {
 const accountNumber = 123
 
 // testSalesforceValidCreate will create a valid record in Salesforce.
-func testSalesforceValidCreate(ctx context.Context, conn connectors.Connector) (string, error) {
+func testSalesforceValidCreate(ctx context.Context, conn connectors.WriteConnector) (string, error) {
 	writeRes, err := conn.Write(ctx, connectors.WriteParams{
 		ObjectName: "Account",
 		RecordData: map[string]interface{}{
@@ -142,7 +152,7 @@ func testSalesforceValidCreate(ctx context.Context, conn connectors.Connector) (
 const accountNumber2 = 456
 
 // testSalesforceValidUpdate will update existing record in Salesforce.
-func testSalesforceValidUpdate(ctx context.Context, conn connectors.Connector, writtenRecordId string) error {
+func testSalesforceValidUpdate(ctx context.Context, conn connectors.WriteConnector, writtenRecordId string) error {
 	writeRes, err := conn.Write(ctx, connectors.WriteParams{
 		ObjectName: "Account",
 		RecordData: map[string]interface{}{
diff --git a/test/utils/mockutils/fileReader.go b/test/utils/mockutils/fileReader.go
new file mode 100644
index 00000000..b6b24f33
--- /dev/null
+++ b/test/utils/mockutils/fileReader.go
@@ -0,0 +1,24 @@
+package mockutils
+
+import (
+	"os"
+	"path"
+	"runtime"
+	"strings"
+)
+
+// 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
+
+	workingDir, err := os.Getwd()
+	if err != nil {
+		return nil, err
+	}
+
+	relativePath, _ := strings.CutPrefix(parentCallerLocation, workingDir)
+	testDir := path.Join(".", relativePath, "../test")
+
+	return os.ReadFile(testDir + "/" + testFileName)
+}
diff --git a/test/utils/mockutils/server.go b/test/utils/mockutils/server.go
new file mode 100644
index 00000000..9398e258
--- /dev/null
+++ b/test/utils/mockutils/server.go
@@ -0,0 +1,25 @@
+package mockutils
+
+import (
+	"fmt"
+	"net/http"
+)
+
+func RespondWithMethodExpectation(w http.ResponseWriter, r *http.Request, methodName string) {
+	// 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)
+	} else {
+		w.WriteHeader(http.StatusBadRequest)
+		WriteBody(w, fmt.Sprintf(`{
+			"error": {
+				"code": "from test",
+				"message":"test server expected %v request"
+			}}`, methodName))
+	}
+}
+
+func WriteBody(w http.ResponseWriter, body string) {
+	_, _ = w.Write([]byte(body))
+}