Repository: amp-labs/connectors
Pull request #1064 (2024-10-07 16:55:18)

feat: Enable read+write: Gong
# Installation
![image](https://github.com/user-attachments/assets/0634435f-7604-4ad2-ba22-9b197ced675a)

# Read
![image](https://github.com/user-attachments/assets/9701bcee-120e-4eeb-bcc3-f2fa63c048ea)
This should count as success. Here are more details for this operation.
![image](https://github.com/user-attachments/assets/a0fd30d2-f50d-41e8-be96-fb4eed79c1bc)
As you can see it says "no calls found". I can replicate this using PROXY.
![image](https://github.com/user-attachments/assets/18f0e257-b2e3-4b9d-9040-eed5a190c7c5)
The message is **identical**. If we modify `Since` parameter to be Jan 2024, since then we have one call:
![image](https://github.com/user-attachments/assets/71cbc93b-de51-4526-b229-7643f17f3a97)
It takes time the whole day for the call to appear after its creation. Querying should happen using "old" date instead of "now".


# Write
Create call.
![image](https://github.com/user-attachments/assets/b6d5db9f-8a5b-46fc-8efd-a9435a939587)
![image](https://github.com/user-attachments/assets/d8c96506-5ff8-4d77-90bd-7b426903dbb7)

# Examples
https://github.com/amp-labs/testdata/pull/16


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

This stack of pull requests is managed by Graphite. <a href="https://stacking.dev/?utm_source=stack-comment">Learn more about stacking.</a>
<h2></h2>

 Join @Cobalt0s and the rest of your teammates on <a href="https://graphite.dev?utm-source=stack-comment"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="11px" height="11px"/> <b>Graphite</b></a>
  <!-- Current dependencies on/for this PR: -->

Patch:
diff --git a/common/urlbuilder/url.go b/common/urlbuilder/url.go
index d853d1d9..daec160e 100644
--- a/common/urlbuilder/url.go
+++ b/common/urlbuilder/url.go
@@ -133,3 +133,7 @@ func cleanTrailingSlashes(link string) string {
 
 	return link
 }
+
+func (u *URL) HasQueryParam(name string) bool {
+	return u.queryParams.Has(name)
+}
diff --git a/go.mod b/go.mod
index 452f9871..f337e6a8 100644
--- a/go.mod
+++ b/go.mod
@@ -4,25 +4,24 @@ go 1.21.0
 
 require (
 	github.com/PuerkitoBio/goquery v1.9.2
-	github.com/antchfx/xmlquery v1.4.1
+	github.com/antchfx/xmlquery v1.4.2
 	github.com/brianvoe/gofakeit/v6 v6.28.0
 	github.com/gertd/go-pluralize v0.2.1
-	github.com/getkin/kin-openapi v0.127.0
+	github.com/getkin/kin-openapi v0.128.0
 	github.com/go-playground/validator v9.31.0+incompatible
 	github.com/go-test/deep v1.1.1
 	github.com/iancoleman/strcase v0.3.0
-	github.com/joho/godotenv v1.5.1
 	github.com/spf13/cobra v1.8.1
 	github.com/spf13/viper v1.19.0
 	github.com/spyzhov/ajson v0.9.4
 	github.com/stretchr/testify v1.9.0
 	golang.org/x/oauth2 v0.23.0
-	golang.org/x/text v0.18.0
+	golang.org/x/text v0.19.0
 )
 
 require (
 	github.com/andybalholm/cascadia v1.3.2 // indirect
-	github.com/antchfx/xpath v1.3.1 // indirect
+	github.com/antchfx/xpath v1.3.2 // indirect
 	github.com/fsnotify/fsnotify v1.7.0 // indirect
 	github.com/go-openapi/jsonpointer v0.21.0 // indirect
 	github.com/go-openapi/swag v0.23.0 // indirect
diff --git a/go.sum b/go.sum
index cf84afb7..b1b19034 100644
--- a/go.sum
+++ b/go.sum
@@ -2,10 +2,10 @@ github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4
 github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
 github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
 github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
-github.com/antchfx/xmlquery v1.4.1 h1:YgpSwbeWvLp557YFTi8E3z6t6/hYjmFEtiEKbDfEbl0=
-github.com/antchfx/xmlquery v1.4.1/go.mod h1:lKezcT8ELGt8kW5L+ckFMTbgdR61/odpPgDv8Gvi1fI=
-github.com/antchfx/xpath v1.3.1 h1:PNbFuUqHwWl0xRjvUPjJ95Agbmdj2uzzIwmQKgu4oCk=
-github.com/antchfx/xpath v1.3.1/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
+github.com/antchfx/xmlquery v1.4.2 h1:MZKd9+wblwxfQ1zd1AdrTsqVaMjMCwow3IqkCSe00KA=
+github.com/antchfx/xmlquery v1.4.2/go.mod h1:QXhvf5ldTuGqhd1SHNvvtlhhdQLks4dD0awIVhXIDTA=
+github.com/antchfx/xpath v1.3.2 h1:LNjzlsSjinu3bQpw9hWMY9ocB80oLOWuQqFvO6xt51U=
+github.com/antchfx/xpath v1.3.2/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
 github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4=
 github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs=
 github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
@@ -19,8 +19,8 @@ github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nos
 github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
 github.com/gertd/go-pluralize v0.2.1 h1:M3uASbVjMnTsPb0PNqg+E/24Vwigyo/tvyMTtAlLgiA=
 github.com/gertd/go-pluralize v0.2.1/go.mod h1:rbYaKDbsXxmRfr8uygAEKhOWsjyrrqrkHVpZvoOp8zk=
-github.com/getkin/kin-openapi v0.127.0 h1:Mghqi3Dhryf3F8vR370nN67pAERW+3a95vomb3MAREY=
-github.com/getkin/kin-openapi v0.127.0/go.mod h1:OZrfXzUfGrNbsKj+xmFBx6E5c6yH3At/tAKSc2UszXM=
+github.com/getkin/kin-openapi v0.128.0 h1:jqq3D9vC9pPq1dGcOCv7yOp1DaEe7c/T1vzcLbITSp4=
+github.com/getkin/kin-openapi v0.128.0/go.mod h1:OZrfXzUfGrNbsKj+xmFBx6E5c6yH3At/tAKSc2UszXM=
 github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
 github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
 github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
@@ -45,8 +45,6 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
 github.com/invopop/yaml v0.3.1 h1:f0+ZpmhfBSS4MhG+4HYseMdJhoeeopbSKbq5Rpeelso=
 github.com/invopop/yaml v0.3.1/go.mod h1:PMOp3nn4/12yEZUFfmOuNHJsZToEEOwoWsT+D81KkeA=
-github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
-github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
 github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
 github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
 github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -147,8 +145,8 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
 golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
 golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
-golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
-golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
+golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
+golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
diff --git a/providers/attio/README.md b/providers/attio/README.md
new file mode 100644
index 00000000..3ad484eb
--- /dev/null
+++ b/providers/attio/README.md
@@ -0,0 +1,16 @@
+# Atio connector
+
+
+## Supported Objects 
+Below is an exhaustive list of objects & methods supported on the objects
+
+Attio API version : v2
+
+| Object | Resource | Method
+| :-------- | :------- | 
+| Objects  | objects | read and write
+| Lists | lists | read and write
+| Workspace members | workspace_members | read
+| Webhooks | webhooks | read and write
+| Tasks  | tasks | read and write
+| Notes  | notes | read and write
\ No newline at end of file
diff --git a/providers/attio/client.go b/providers/attio/client.go
new file mode 100644
index 00000000..07897728
--- /dev/null
+++ b/providers/attio/client.go
@@ -0,0 +1,16 @@
+package attio
+
+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/providers/attio/connector.go b/providers/attio/connector.go
new file mode 100644
index 00000000..4c83f740
--- /dev/null
+++ b/providers/attio/connector.go
@@ -0,0 +1,63 @@
+package attio
+
+import (
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/paramsbuilder"
+	"github.com/amp-labs/connectors/common/urlbuilder"
+	"github.com/amp-labs/connectors/providers"
+)
+
+const (
+	apiVersion = "v2"
+)
+
+type Connector struct {
+	BaseURL string
+	Client  *common.JSONHTTPClient
+}
+
+func NewConnector(opts ...Option) (conn *Connector, outErr error) {
+	defer common.PanicRecovery(func(cause error) {
+		outErr = cause
+		conn = nil
+	})
+
+	params, err := paramsbuilder.Apply(parameters{}, opts)
+	if err != nil {
+		return nil, err
+	}
+
+	conn = &Connector{
+		Client: &common.JSONHTTPClient{
+			HTTPClient: params.Client.Caller,
+		},
+	}
+
+	// Read provider info
+	providerInfo, err := providers.ReadInfo(conn.Provider())
+	if err != nil {
+		return nil, err
+	}
+
+	conn.setBaseURL(providerInfo.BaseURL)
+
+	return conn, nil
+}
+
+func (c *Connector) getApiURL(arg string) (*urlbuilder.URL, error) {
+	return urlbuilder.New(c.BaseURL, apiVersion, arg)
+}
+
+func (c *Connector) setBaseURL(newURL string) {
+	c.BaseURL = newURL
+	c.Client.HTTPClient.Base = newURL
+}
+
+// Provider returns the connector provider.
+func (c *Connector) Provider() providers.Provider {
+	return providers.Attio
+}
+
+func (c *Connector) String() string {
+	return c.Provider() + ".Connector"
+}
diff --git a/providers/attio/metadata.go b/providers/attio/metadata.go
new file mode 100644
index 00000000..730e0e69
--- /dev/null
+++ b/providers/attio/metadata.go
@@ -0,0 +1,78 @@
+package attio
+
+import (
+	"context"
+
+	"github.com/amp-labs/connectors/common"
+)
+
+// This struct is used for when the response data having slice of data.
+type responseObject struct {
+	Data []map[string]any `json:"data"`
+}
+
+// ListObjectMetadata creates metadata of object via reading objects using Attio API.
+func (c *Connector) ListObjectMetadata(ctx context.Context,
+	objectNames []string,
+) (*common.ListObjectMetadataResult, error) {
+	if len(objectNames) == 0 {
+		return nil, common.ErrMissingObjects
+	}
+
+	metadataResult := common.ListObjectMetadataResult{
+		Result: make(map[string]common.ObjectMetadata),
+		Errors: make(map[string]error),
+	}
+
+	for _, obj := range objectNames {
+		// Constructing the request url.
+		url, err := c.getApiURL(obj)
+		if err != nil {
+			return nil, err
+		}
+
+		resp, err := c.Client.Get(ctx, url.String())
+		if err != nil {
+			metadataResult.Errors[obj] = err
+
+			continue
+		}
+
+		var metadata *common.ObjectMetadata
+
+		metadata, err = parseMetadataFromResponse(resp)
+		if err != nil {
+			metadataResult.Errors[obj] = err
+
+			continue
+		}
+
+		metadata.DisplayName = obj
+		metadataResult.Result[obj] = *metadata
+	}
+
+	return &metadataResult, nil
+}
+
+// This function is used for slice of objects.
+func parseMetadataFromResponse(resp *common.JSONHTTPResponse) (*common.ObjectMetadata, error) {
+	response, err := common.UnmarshalJSON[responseObject](resp)
+	if err != nil {
+		return nil, err
+	}
+
+	if len(response.Data) == 0 {
+		return nil, common.ErrMissingExpectedValues
+	}
+
+	metadata := &common.ObjectMetadata{
+		FieldsMap: make(map[string]string),
+	}
+
+	// Using the first result data to generate the metadata.
+	for k := range response.Data[0] {
+		metadata.FieldsMap[k] = k
+	}
+
+	return metadata, nil
+}
diff --git a/providers/attio/objectNames.go b/providers/attio/objectNames.go
new file mode 100644
index 00000000..dd98ef22
--- /dev/null
+++ b/providers/attio/objectNames.go
@@ -0,0 +1,35 @@
+package attio
+
+import "github.com/amp-labs/connectors/common/handy"
+
+const (
+	objectNameObjects          = "objects"
+	objectNameLists            = "lists"
+	objectNameWorkspacemembers = "workspace_members"
+	objectNameWebhooks         = "webhooks"
+	objectNameTasks            = "tasks"
+	objectNameNotes            = "notes"
+)
+
+var supportedObjectsByRead = handy.NewSet( //nolint:gochecknoglobals
+	objectNameObjects,
+	objectNameLists,
+	objectNameWorkspacemembers,
+	objectNameWebhooks,
+	objectNameTasks,
+	objectNameNotes,
+)
+
+var supportedObjectsByWrite = handy.NewSet( //nolint:gochecknoglobals
+	objectNameObjects,
+	objectNameLists,
+	objectNameTasks,
+	objectNameNotes,
+	objectNameWebhooks,
+)
+
+var supportLimitAndOffset = handy.NewSet( //nolint:gochecknoglobals
+	objectNameTasks,
+	objectNameNotes,
+	objectNameWebhooks,
+)
diff --git a/providers/attio/params.go b/providers/attio/params.go
new file mode 100644
index 00000000..edfccc54
--- /dev/null
+++ b/providers/attio/params.go
@@ -0,0 +1,42 @@
+package attio
+
+import (
+	"context"
+	"errors"
+	"net/http"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/paramsbuilder"
+	"golang.org/x/oauth2"
+)
+
+type Option = func(params *parameters)
+
+type parameters struct {
+	paramsbuilder.Client
+}
+
+const (
+	// DefaultPageSize is number of elements per page.
+	DefaultPageSize = 10
+)
+
+func (p parameters) ValidateParams() error {
+	return errors.Join(
+		p.Client.ValidateParams(),
+	)
+}
+
+func WithClient(ctx context.Context, client *http.Client,
+	config *oauth2.Config, token *oauth2.Token, opts ...common.OAuthOption,
+) Option {
+	return func(params *parameters) {
+		params.WithOauthClient(ctx, client, config, token, opts...)
+	}
+}
+
+func WithAuthenticatedClient(client common.AuthenticatedHTTPClient) Option {
+	return func(params *parameters) {
+		params.WithAuthenticatedClient(client)
+	}
+}
diff --git a/providers/attio/parse.go b/providers/attio/parse.go
new file mode 100644
index 00000000..46de76aa
--- /dev/null
+++ b/providers/attio/parse.go
@@ -0,0 +1,42 @@
+package attio
+
+import (
+	"strconv"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/jsonquery"
+	"github.com/amp-labs/connectors/common/urlbuilder"
+	"github.com/spyzhov/ajson"
+)
+
+func makeNextRecordsURL(reqLink *urlbuilder.URL) common.NextPageFunc {
+	return func(node *ajson.Node) (string, error) {
+		previousStart := 0
+
+		// Extract the data key value from the response.
+		value, err := jsonquery.New(node).Array("data", false)
+		if err != nil {
+			return "", err
+		}
+
+		if (reqLink.HasQueryParam("limit") || reqLink.HasQueryParam("offset")) && len(value) != 0 {
+			offsetQP, ok := reqLink.GetFirstQueryParam("offset")
+			if ok {
+				// Try to use previous "offset" parameter to determine the next offset.
+				offsetNum, err := strconv.Atoi(offsetQP)
+				if err == nil {
+					previousStart = offsetNum
+				}
+			}
+
+			nextStart := previousStart + DefaultPageSize
+
+			reqLink.WithQueryParam("limit", strconv.Itoa(DefaultPageSize))
+			reqLink.WithQueryParam("offset", strconv.Itoa(nextStart))
+
+			return reqLink.String(), nil
+		}
+
+		return "", nil
+	}
+}
diff --git a/providers/attio/read.go b/providers/attio/read.go
new file mode 100644
index 00000000..12373c2d
--- /dev/null
+++ b/providers/attio/read.go
@@ -0,0 +1,56 @@
+package attio
+
+import (
+	"context"
+	"strconv"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/urlbuilder"
+)
+
+func (c *Connector) Read(ctx context.Context, config common.ReadParams) (*common.ReadResult, error) {
+	if err := config.ValidateParams(true); err != nil {
+		return nil, err
+	}
+
+	if !supportedObjectsByRead.Has(config.ObjectName) {
+		return nil, common.ErrOperationNotSupportedForObject
+	}
+
+	url, err := c.buildURL(config)
+	if err != nil {
+		return nil, err
+	}
+
+	rsp, err := c.Client.Get(ctx, url.String())
+	if err != nil {
+		return nil, err
+	}
+
+	return common.ParseResult(
+		rsp,
+		common.GetRecordsUnderJSONPath("data"),
+		makeNextRecordsURL(url),
+		common.GetMarshaledData,
+		config.Fields,
+	)
+}
+
+func (c *Connector) buildURL(config common.ReadParams) (*urlbuilder.URL, error) {
+	if len(config.NextPage) != 0 {
+		// Next page.
+		return urlbuilder.New(config.NextPage.String())
+	}
+
+	url, err := c.getApiURL(config.ObjectName)
+	if err != nil {
+		return nil, err
+	}
+
+	if supportLimitAndOffset.Has(config.ObjectName) {
+		url.WithQueryParam("limit", strconv.Itoa(DefaultPageSize))
+		url.WithQueryParam("offset", "0")
+	}
+
+	return url, nil
+}
diff --git a/providers/attio/write.go b/providers/attio/write.go
new file mode 100644
index 00000000..c32d84f4
--- /dev/null
+++ b/providers/attio/write.go
@@ -0,0 +1,62 @@
+// nolint
+package attio
+
+import (
+	"context"
+	"errors"
+
+	"github.com/amp-labs/connectors/common"
+)
+
+var ErrEmptyResultResponse = errors.New("writing reponded with an empty result")
+
+type writeResponse struct {
+	Success bool           `json:"success"`
+	Data    map[string]any `json:"data"`
+}
+
+// Write creates/updates records in attio.
+func (c *Connector) Write(ctx context.Context, config common.WriteParams) (*common.WriteResult, error) {
+	if err := config.ValidateParams(); err != nil {
+		return nil, err
+	}
+
+	if !supportedObjectsByWrite.Has(config.ObjectName) {
+		return nil, common.ErrOperationNotSupportedForObject
+	}
+
+	url, err := c.getApiURL(config.ObjectName)
+	if err != nil {
+		return nil, err
+	}
+
+	var write common.WriteMethod
+	if len(config.RecordId) == 0 {
+		// writing to the entity without id means creating a new record.
+		write = c.Client.Post
+	} else {
+		// updating resource by patch method.
+		write = c.Client.Patch
+
+		url.AddPath(config.RecordId)
+	}
+
+	res, err := write(ctx, url.String(), config.RecordData)
+	if err != nil {
+		return nil, err
+	}
+
+	resp, err := common.UnmarshalJSON[writeResponse](res)
+	if err != nil {
+		return nil, err
+	}
+
+	if res.Code == 200 {
+		resp.Success = true
+	}
+
+	return &common.WriteResult{
+		Success: resp.Success,
+		Data:    resp.Data,
+	}, nil
+}
diff --git a/providers/gong.go b/providers/gong.go
index cd751607..970c9bf4 100644
--- a/providers/gong.go
+++ b/providers/gong.go
@@ -36,9 +36,9 @@ func init() {
 				Delete: false,
 			},
 			Proxy:     true,
-			Read:      false,
+			Read:      true,
 			Subscribe: false,
-			Write:     false,
+			Write:     true,
 		},
 	})
 }
diff --git a/scripts/openapi/marketo/metadata/metadata.go b/scripts/openapi/marketo/metadata/metadata.go
index 755f59e7..3e9a782f 100644
--- a/scripts/openapi/marketo/metadata/metadata.go
+++ b/scripts/openapi/marketo/metadata/metadata.go
@@ -2,14 +2,12 @@ package main
 
 import (
 	"encoding/json"
-	"fmt"
 	"log/slog"
 	"os"
 	"strings"
 
 	"github.com/amp-labs/connectors/tools/scrapper"
 	"github.com/getkin/kin-openapi/openapi2"
-	"github.com/getkin/kin-openapi/openapi3"
 )
 
 var (
@@ -44,16 +42,10 @@ func main() {
 	objectMetadata := make(map[string]scrapper.ObjectMetadata)
 
 	// Add Lead metadata details
-	objectMetadata, err = generateMetadata(ldef, docL, objectMetadata)
-	if err != nil {
-		panic(err)
-	}
+	objectMetadata = generateMetadata(ldef, docL, objectMetadata)
 
 	// Adds Assets Metadata details to the same variable declared above.
-	objectMetadata, err = generateMetadata(def, docA, objectMetadata)
-	if err != nil {
-		panic(err)
-	}
+	objectMetadata = generateMetadata(def, docA, objectMetadata)
 
 	// wrap objectMetadata in `data` to not break the fileManager that reads the schema.
 	data := map[string]any{
@@ -132,22 +124,14 @@ func cleanDefinitions(def string) string {
 
 func generateMetadata(objDefs map[string]string,
 	doc openapi2.T, objectMetadata map[string]scrapper.ObjectMetadata,
-) (map[string]scrapper.ObjectMetadata, error) {
+) map[string]scrapper.ObjectMetadata {
 	for obj, dfn := range objDefs {
-		schemas := doc.Definitions[dfn].Value.Properties
+		schem := doc.Definitions[dfn].Value.Properties
 
 		// Reading the item key that will contain the metadata keys.
-		result, err := schemas["result"].Value.JSONLookup("items")
-		if err != nil {
-			return nil, err
-		}
-
-		r, ok := result.(*openapi3.Ref)
-		if !ok {
-			return nil, fmt.Errorf("failed to convert the response type into an openapi3 type") //nolint:goerr113
-		}
+		result := schem["result"].Value.Items
 
-		m := cleanDefinitions(r.Ref)
+		m := cleanDefinitions(result.Ref)
 		lschems := doc.Definitions[m].Value.Properties
 
 		fields := make(map[string]string)
@@ -164,5 +148,5 @@ func generateMetadata(objDefs map[string]string,
 		objectMetadata[obj] = om
 	}
 
-	return objectMetadata, nil
+	return objectMetadata
 }


Test patch:
diff --git a/providers/attio/metadata_test.go b/providers/attio/metadata_test.go
new file mode 100644
index 00000000..ab5508d4
--- /dev/null
+++ b/providers/attio/metadata_test.go
@@ -0,0 +1,165 @@
+// nolint
+package attio
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/amp-labs/connectors"
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/test/utils/mockutils"
+	"github.com/amp-labs/connectors/test/utils/mockutils/mockserver"
+	"github.com/amp-labs/connectors/test/utils/testroutines"
+	"github.com/amp-labs/connectors/test/utils/testutils"
+)
+
+func TestListObjectMetadata(t *testing.T) { // nolint:funlen,gocognit,cyclop
+	t.Parallel()
+
+	objectresponse := testutils.DataFromFile(t, "objects.json")
+	listresponse := testutils.DataFromFile(t, "lists.json")
+	notesresponse := testutils.DataFromFile(t, "notes.json")
+	workspacemembersresponse := testutils.DataFromFile(t, "workspace_members.json")
+	webhooksresponse := testutils.DataFromFile(t, "webhooks.json")
+	tasksresponse := testutils.DataFromFile(t, "tasks.json")
+
+	tests := []testroutines.Metadata{
+		{
+			Name:         "Object must be included",
+			Server:       mockserver.Dummy(),
+			ExpectedErrs: []error{common.ErrMissingObjects},
+		},
+		{
+			Name:  "Successfully describe multiple object with metadata",
+			Input: []string{"objects", "lists", "workspace_members", "notes", "webhooks", "tasks"},
+			Server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				// Simulating different behavior based on URL path
+				w.Header().Set("Content-Type", "application/json")
+				w.WriteHeader(http.StatusOK)
+				switch r.URL.Path {
+				case "/v2/objects":
+					_, _ = w.Write(objectresponse)
+				case "/v2/lists":
+					_, _ = w.Write(listresponse)
+				case "/v2/workspace_members":
+					_, _ = w.Write(workspacemembersresponse)
+				case "/v2/notes":
+					_, _ = w.Write(notesresponse)
+				case "/v2/tasks":
+					_, _ = w.Write(tasksresponse)
+				case "/v2/webhooks":
+					_, _ = w.Write(webhooksresponse)
+				default:
+					// Return 400 for any unexpected paths
+					http.Error(w, "Invalid URL", http.StatusBadRequest)
+				}
+			})),
+			Comparator: func(baseURL string, actual, expected *common.ListObjectMetadataResult) bool {
+				return mockutils.MetadataResultComparator.SubsetFields(actual, expected)
+			},
+			Expected: &common.ListObjectMetadataResult{
+				Result: map[string]common.ObjectMetadata{
+					"objects": {
+						DisplayName: "objects",
+						FieldsMap: map[string]string{
+							"api_slug":      "api_slug",
+							"created_at":    "created_at",
+							"id":            "id",
+							"plural_noun":   "plural_noun",
+							"singular_noun": "singular_noun",
+						},
+					},
+					"lists": {
+						DisplayName: "lists",
+						FieldsMap: map[string]string{
+							"api_slug":                "api_slug",
+							"created_at":              "created_at",
+							"created_by_actor":        "created_by_actor",
+							"id":                      "id",
+							"name":                    "name",
+							"parent_object":           "parent_object",
+							"workspace_access":        "workspace_access",
+							"workspace_member_access": "workspace_member_access",
+						},
+					},
+					"workspace_members": {
+						DisplayName: "workspace_members",
+						FieldsMap: map[string]string{
+							"access_level":  "access_level",
+							"avatar_url":    "avatar_url",
+							"created_at":    "created_at",
+							"email_address": "email_address",
+							"first_name":    "first_name",
+							"id":            "id",
+							"last_name":     "last_name",
+						},
+					},
+					"webhooks": {
+						DisplayName: "webhooks",
+						FieldsMap: map[string]string{
+							"created_at":    "created_at",
+							"id":            "id",
+							"status":        "status",
+							"subscriptions": "subscriptions",
+							"target_url":    "target_url",
+						},
+					},
+					"notes": {
+						DisplayName: "notes",
+						FieldsMap: map[string]string{
+							"content_plaintext": "content_plaintext",
+							"created_at":        "created_at",
+							"created_by_actor":  "created_by_actor",
+							"id":                "id",
+							"parent_object":     "parent_object",
+							"parent_record_id":  "parent_record_id",
+							"title":             "title",
+						},
+					},
+					"tasks": {
+						DisplayName: "tasks",
+						FieldsMap: map[string]string{
+							"assignees":         "assignees",
+							"content_plaintext": "content_plaintext",
+							"created_at":        "created_at",
+							"created_by_actor":  "created_by_actor",
+							"deadline_at":       "deadline_at",
+							"id":                "id",
+							"is_completed":      "is_completed",
+							"linked_records":    "linked_records",
+						},
+					},
+				},
+				Errors: nil,
+			},
+			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()
+
+			tt.Run(t, func() (connectors.ObjectMetadataConnector, error) {
+				return constructTestConnector(tt.Server.URL)
+			})
+		})
+	}
+}
+
+func constructTestConnector(serverURL string) (*Connector, error) {
+	connector, err := NewConnector(
+		WithAuthenticatedClient(http.DefaultClient),
+	)
+
+	if err != nil {
+		return nil, err
+	}
+	// for testing we want to redirect calls to our mock server.
+	connector.setBaseURL(serverURL)
+
+	return connector, nil
+}
diff --git a/providers/attio/read_test.go b/providers/attio/read_test.go
new file mode 100644
index 00000000..3fb9a475
--- /dev/null
+++ b/providers/attio/read_test.go
@@ -0,0 +1,315 @@
+// nolint
+package attio
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/amp-labs/connectors"
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/test/utils/mockutils"
+	"github.com/amp-labs/connectors/test/utils/mockutils/mockserver"
+	"github.com/amp-labs/connectors/test/utils/testroutines"
+	"github.com/amp-labs/connectors/test/utils/testutils"
+)
+
+func TestRead(t *testing.T) { // nolint:funlen,gocognit,cyclop
+	t.Parallel()
+
+	responseObjects := testutils.DataFromFile(t, "objects.json")
+	responseLists := testutils.DataFromFile(t, "lists.json")
+	responseWorkspace := testutils.DataFromFile(t, "workspace_members.json")
+	responseNotes := testutils.DataFromFile(t, "notes.json")
+	responseTasks := testutils.DataFromFile(t, "tasks.json")
+	responseWebhooks := testutils.DataFromFile(t, "webhooks.json")
+
+	tests := []testroutines.Read{
+		{
+			Name:         "Read object must be included",
+			Server:       mockserver.Dummy(),
+			ExpectedErrs: []error{common.ErrMissingObjects},
+		},
+		{
+			Name:         "At least one field is requested",
+			Input:        common.ReadParams{ObjectName: "objects"},
+			Server:       mockserver.Dummy(),
+			ExpectedErrs: []error{common.ErrMissingFields},
+		},
+		{
+			Name:         "Unknown objects are not supported",
+			Input:        common.ReadParams{ObjectName: "attributes", Fields: connectors.Fields("")},
+			Server:       mockserver.Dummy(),
+			ExpectedErrs: []error{common.ErrOperationNotSupportedForObject},
+		},
+		{
+			Name:  "An Empty response",
+			Input: common.ReadParams{ObjectName: "webhooks", Fields: connectors.Fields("")},
+			Server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				w.WriteHeader(http.StatusOK)
+				mockutils.WriteBody(w, `{"data":[]}`)
+			})),
+			Expected: &common.ReadResult{
+				Rows: 0,
+				Data: []common.ReadResultRow{},
+				Done: true,
+			},
+			ExpectedErrs: nil,
+		},
+		{
+			Name:  "Read list of all objects",
+			Input: common.ReadParams{ObjectName: "objects", Fields: connectors.Fields("")},
+			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(responseObjects)
+			})),
+			Expected: &common.ReadResult{
+				Rows: 2,
+				Data: []common.ReadResultRow{{
+					Fields: map[string]any{},
+					Raw: map[string]any{
+						"id": map[string]any{
+							"workspace_id": "0d4d7fa2-d6e8-4a61-a7dc-e178405ff3c6",
+							"object_id":    "9aff8088-dd1f-4e98-ad76-b1e49b9a419a",
+						},
+						"api_slug":      "people",
+						"singular_noun": "Person",
+						"plural_noun":   "People",
+						"created_at":    "2024-09-17T11:41:18.736000000Z",
+					},
+				}, {
+					Fields: map[string]any{},
+					Raw: map[string]any{
+						"id": map[string]any{
+							"workspace_id": "0d4d7fa2-d6e8-4a61-a7dc-e178405ff3c6",
+							"object_id":    "f4df082c-b46e-43e4-a747-f7918b487f44",
+						},
+						"api_slug":      "companies",
+						"singular_noun": "Company",
+						"plural_noun":   "Companies",
+						"created_at":    "2024-09-17T11:41:18.736000000Z",
+					},
+				}},
+				NextPage: "",
+				Done:     true,
+			},
+			ExpectedErrs: nil,
+		},
+		{
+			Name:  "Read list of all lists",
+			Input: common.ReadParams{ObjectName: "lists", Fields: connectors.Fields("")},
+			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(responseLists)
+			})),
+			Expected: &common.ReadResult{
+				Rows: 1,
+				Data: []common.ReadResultRow{{
+					Fields: map[string]any{},
+					Raw: map[string]any{
+						"id": map[string]any{
+							"workspace_id": "0d4d7fa2-d6e8-4a61-a7dc-e178405ff3c6",
+							"list_id":      "7ddc974a-2ab2-4a96-a83e-853eacb0329f",
+						},
+						"api_slug":         "sales_6",
+						"created_at":       "2024-09-25T13:10:33.302000000Z",
+						"name":             "Sales",
+						"workspace_access": nil,
+						"workspace_member_access": []interface{}{
+							map[string]any{
+								"level":               "full-access",
+								"workspace_member_id": "cc3821aa-f738-42c0-a739-7b6de755e5f1",
+							},
+						},
+						"parent_object": []any{
+							"companies",
+						},
+						"created_by_actor": map[string]any{
+							"type": "workspace-member",
+							"id":   "cc3821aa-f738-42c0-a739-7b6de755e5f1",
+						},
+					},
+				},
+				},
+				NextPage: "",
+				Done:     true,
+			},
+			ExpectedErrs: nil,
+		},
+		{
+			Name:  "Read list of all workspace members",
+			Input: common.ReadParams{ObjectName: "workspace_members", Fields: connectors.Fields("")},
+			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(responseWorkspace)
+			})),
+			Expected: &common.ReadResult{
+				Rows: 2,
+				Data: []common.ReadResultRow{{
+					Fields: map[string]any{},
+					Raw: map[string]any{
+						"id": map[string]any{
+							"workspace_id":        "0d4d7fa2-d6e8-4a61-a7dc-e178405ff3c6",
+							"workspace_member_id": "67af46e4-a450-4fee-a1d1-39729b3af771",
+						},
+						"first_name":    "Integration",
+						"last_name":     "User",
+						"avatar_url":    nil,
+						"email_address": "integration.user@withampersand.com",
+						"access_level":  "admin",
+						"created_at":    "2024-09-17T11:41:21.779000000Z",
+					},
+				}, {
+					Fields: map[string]any{},
+					Raw: map[string]any{
+						"id": map[string]any{
+							"workspace_id":        "0d4d7fa2-d6e8-4a61-a7dc-e178405ff3c6",
+							"workspace_member_id": "cc3821aa-f738-42c0-a739-7b6de755e5f1",
+						},
+						"first_name":    "Sanjay Kanth",
+						"last_name":     "A",
+						"avatar_url":    "https://lh3.googleusercontent.com/a/ACg8ocL0Zwi9XArWL-GgYiUqPoDMKS1p1AQuQPPHVMQr0V023Ox_fYY=s96-c",
+						"email_address": "sanjaykanth.a@mitrahsoft.com",
+						"access_level":  "admin",
+						"created_at":    "2024-09-17T12:17:11.682000000Z",
+					},
+				}},
+				NextPage: "",
+				Done:     true,
+			},
+			ExpectedErrs: nil,
+		},
+		{
+			Name:  "Read list of all notes",
+			Input: common.ReadParams{ObjectName: "notes", Fields: connectors.Fields(""), NextPage: "test?limit=10"},
+			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(responseNotes)
+			})),
+			Expected: &common.ReadResult{
+				Rows: 1,
+				Data: []common.ReadResultRow{{
+					Fields: map[string]any{},
+					Raw: map[string]any{
+						"id": map[string]any{
+							"workspace_id": "0d4d7fa2-d6e8-4a61-a7dc-e178405ff3c6",
+							"note_id":      "32dc76ee-d094-40e1-b176-0f8e1b772f0a",
+						},
+						"parent_object":     "companies",
+						"parent_record_id":  "ec902ed9-aab7-4347-8e26-dca240ffba08",
+						"title":             "value",
+						"content_plaintext": "",
+						"created_by_actor": map[string]any{
+							"type": "workspace-member",
+							"id":   "cc3821aa-f738-42c0-a739-7b6de755e5f1",
+						},
+						"created_at": "2024-09-24T08:31:09.211000000Z",
+					},
+				},
+				},
+				NextPage: "test?limit=10&offset=10",
+				Done:     false,
+			},
+			ExpectedErrs: nil,
+		},
+		{
+			Name:  "Read list of all tasks",
+			Input: common.ReadParams{ObjectName: "tasks", Fields: connectors.Fields(""), NextPage: "test?limit=10"},
+			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(responseTasks)
+			})),
+			Expected: &common.ReadResult{
+				Rows: 1,
+				Data: []common.ReadResultRow{{
+					Fields: map[string]any{},
+					Raw: map[string]any{
+						"id": map[string]any{
+							"workspace_id": "0d4d7fa2-d6e8-4a61-a7dc-e178405ff3c6",
+							"task_id":      "4a585693-fa14-4ead-9e19-cc9251df59be",
+						},
+						"content_plaintext": "Follow up on current software solutions",
+						"is_completed":      false,
+						"deadline_at":       "2023-01-01T15:00:00.000000000Z",
+						"linked_records":    []any{},
+						"assignees": []interface{}{
+							map[string]any{
+								"referenced_actor_type": "workspace-member",
+								"referenced_actor_id":   "67af46e4-a450-4fee-a1d1-39729b3af771",
+							},
+						},
+						"created_by_actor": map[string]any{
+							"type": "api-token",
+							"id":   "53b1e97a-08d6-4d2e-856d-5371bb6f4052",
+						},
+						"created_at": "2024-09-23T11:04:07.647000000Z",
+					},
+				},
+				},
+				NextPage: "test?limit=10&offset=10",
+				Done:     false,
+			},
+			ExpectedErrs: nil,
+		},
+		{
+			Name:  "Read list of all webhooks",
+			Input: common.ReadParams{ObjectName: "webhooks", Fields: connectors.Fields(""), NextPage: "test?limit=10"},
+			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(responseWebhooks)
+			})),
+			Expected: &common.ReadResult{
+				Rows: 1,
+				Data: []common.ReadResultRow{{
+					Fields: map[string]any{},
+					Raw: map[string]any{
+						"id": map[string]any{
+							"workspace_id": "0d4d7fa2-d6e8-4a61-a7dc-e178405ff3c6",
+							"webhook_id":   "02303005-d363-4f49-8fd2-19944003b1d2",
+						},
+						"target_url": "https://example.com/webhook",
+						"status":     "active",
+						"subscriptions": []interface{}{
+							map[string]any{
+								"event_type": "note.created",
+								"filter": map[string]any{
+									"$or": []interface{}{
+										map[string]any{
+											"field":    "id.list_id",
+											"operator": "equals",
+											"value":    "d34c5f6b-0410-4830-853d-8a6602252687",
+										},
+									},
+								},
+							},
+						},
+						"created_at": "2024-09-20T11:14:17.403000000Z",
+					},
+				},
+				},
+				NextPage: "test?limit=10&offset=10",
+				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()
+
+			tt.Run(t, func() (connectors.ReadConnector, error) {
+				return constructTestConnector(tt.Server.URL)
+			})
+		})
+	}
+}
diff --git a/providers/attio/test/lists.json b/providers/attio/test/lists.json
new file mode 100644
index 00000000..316d7891
--- /dev/null
+++ b/providers/attio/test/lists.json
@@ -0,0 +1,27 @@
+{
+    "data": [
+        {
+            "id": {
+                "workspace_id": "0d4d7fa2-d6e8-4a61-a7dc-e178405ff3c6",
+                "list_id": "7ddc974a-2ab2-4a96-a83e-853eacb0329f"
+            },
+            "api_slug": "sales_6",
+            "created_at": "2024-09-25T13:10:33.302000000Z",
+            "name": "Sales",
+            "workspace_access": null,
+            "workspace_member_access": [
+                {
+                    "level": "full-access",
+                    "workspace_member_id": "cc3821aa-f738-42c0-a739-7b6de755e5f1"
+                }
+            ],
+            "parent_object": [
+                "companies"
+            ],
+            "created_by_actor": {
+                "type": "workspace-member",
+                "id": "cc3821aa-f738-42c0-a739-7b6de755e5f1"
+            }
+        }
+    ]
+}
\ No newline at end of file
diff --git a/providers/attio/test/notes.json b/providers/attio/test/notes.json
new file mode 100644
index 00000000..4e280652
--- /dev/null
+++ b/providers/attio/test/notes.json
@@ -0,0 +1,19 @@
+{
+    "data": [
+        {
+            "id": {
+                "workspace_id": "0d4d7fa2-d6e8-4a61-a7dc-e178405ff3c6",
+                "note_id": "32dc76ee-d094-40e1-b176-0f8e1b772f0a"
+            },
+            "parent_object": "companies",
+            "parent_record_id": "ec902ed9-aab7-4347-8e26-dca240ffba08",
+            "title": "value",
+            "content_plaintext": "",
+            "created_by_actor": {
+                "type": "workspace-member",
+                "id": "cc3821aa-f738-42c0-a739-7b6de755e5f1"
+            },
+            "created_at": "2024-09-24T08:31:09.211000000Z"
+        }
+    ]
+}
\ No newline at end of file
diff --git a/providers/attio/test/objects.json b/providers/attio/test/objects.json
new file mode 100644
index 00000000..6e381ff9
--- /dev/null
+++ b/providers/attio/test/objects.json
@@ -0,0 +1,24 @@
+{
+    "data": [
+        {
+            "id": {
+                "workspace_id": "0d4d7fa2-d6e8-4a61-a7dc-e178405ff3c6",
+                "object_id": "9aff8088-dd1f-4e98-ad76-b1e49b9a419a"
+            },
+            "api_slug": "people",
+            "singular_noun": "Person",
+            "plural_noun": "People",
+            "created_at": "2024-09-17T11:41:18.736000000Z"
+        },
+        {
+            "id": {
+                "workspace_id": "0d4d7fa2-d6e8-4a61-a7dc-e178405ff3c6",
+                "object_id": "f4df082c-b46e-43e4-a747-f7918b487f44"
+            },
+            "api_slug": "companies",
+            "singular_noun": "Company",
+            "plural_noun": "Companies",
+            "created_at": "2024-09-17T11:41:18.736000000Z"
+        }
+    ]
+}
\ No newline at end of file
diff --git a/providers/attio/test/tasks.json b/providers/attio/test/tasks.json
new file mode 100644
index 00000000..aa7ed4d3
--- /dev/null
+++ b/providers/attio/test/tasks.json
@@ -0,0 +1,25 @@
+{
+    "data": [
+        {
+            "id": {
+                "workspace_id": "0d4d7fa2-d6e8-4a61-a7dc-e178405ff3c6",
+                "task_id": "4a585693-fa14-4ead-9e19-cc9251df59be"
+            },
+            "content_plaintext": "Follow up on current software solutions",
+            "is_completed": false,
+            "deadline_at": "2023-01-01T15:00:00.000000000Z",
+            "linked_records": [],
+            "assignees": [
+                {
+                    "referenced_actor_type": "workspace-member",
+                    "referenced_actor_id": "67af46e4-a450-4fee-a1d1-39729b3af771"
+                }
+            ],
+            "created_by_actor": {
+                "type": "api-token",
+                "id": "53b1e97a-08d6-4d2e-856d-5371bb6f4052"
+            },
+            "created_at": "2024-09-23T11:04:07.647000000Z"
+        }
+    ]
+}
\ No newline at end of file
diff --git a/providers/attio/test/webhooks.json b/providers/attio/test/webhooks.json
new file mode 100644
index 00000000..88566a1e
--- /dev/null
+++ b/providers/attio/test/webhooks.json
@@ -0,0 +1,27 @@
+{
+    "data": [
+        {
+            "id": {
+                "workspace_id": "0d4d7fa2-d6e8-4a61-a7dc-e178405ff3c6",
+                "webhook_id": "02303005-d363-4f49-8fd2-19944003b1d2"
+            },
+            "target_url": "https://example.com/webhook",
+            "status": "active",
+            "subscriptions": [
+                {
+                    "event_type": "note.created",
+                    "filter": {
+                        "$or": [
+                            {
+                                "field": "id.list_id",
+                                "operator": "equals",
+                                "value": "d34c5f6b-0410-4830-853d-8a6602252687"
+                            }
+                        ]
+                    }
+                }
+            ],
+            "created_at": "2024-09-20T11:14:17.403000000Z"
+        }
+    ]
+}
\ No newline at end of file
diff --git a/providers/attio/test/workspace_members.json b/providers/attio/test/workspace_members.json
new file mode 100644
index 00000000..657b85d6
--- /dev/null
+++ b/providers/attio/test/workspace_members.json
@@ -0,0 +1,28 @@
+{
+    "data": [
+        {
+            "id": {
+                "workspace_id": "0d4d7fa2-d6e8-4a61-a7dc-e178405ff3c6",
+                "workspace_member_id": "67af46e4-a450-4fee-a1d1-39729b3af771"
+            },
+            "first_name": "Integration",
+            "last_name": "User",
+            "avatar_url": null,
+            "email_address": "integration.user@withampersand.com",
+            "access_level": "admin",
+            "created_at": "2024-09-17T11:41:21.779000000Z"
+        },
+        {
+            "id": {
+                "workspace_id": "0d4d7fa2-d6e8-4a61-a7dc-e178405ff3c6",
+                "workspace_member_id": "cc3821aa-f738-42c0-a739-7b6de755e5f1"
+            },
+            "first_name": "Sanjay Kanth",
+            "last_name": "A",
+            "avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocL0Zwi9XArWL-GgYiUqPoDMKS1p1AQuQPPHVMQr0V023Ox_fYY=s96-c",
+            "email_address": "sanjaykanth.a@mitrahsoft.com",
+            "access_level": "admin",
+            "created_at": "2024-09-17T12:17:11.682000000Z"
+        }
+    ]
+}
\ No newline at end of file
diff --git a/providers/attio/test/write_lists.json b/providers/attio/test/write_lists.json
new file mode 100644
index 00000000..e8f5b8d7
--- /dev/null
+++ b/providers/attio/test/write_lists.json
@@ -0,0 +1,25 @@
+{
+"data": {
+    "api_slug": "sales_investing",
+    "created_at": "2024-10-04T10:03:02.070000000Z",
+    "created_by_actor": {
+     "id": "00bcd19c-9a89-467f-bf61-4e1a7f8b8754",
+     "type": "api-token"
+    },
+    "id": {
+     "list_id": "e09a041c-0555-4bb2-8f6e-997bfc9b54e8",
+     "workspace_id": "0d4d7fa2-d6e8-4a61-a7dc-e178405ff3c6"        
+    },
+    "name": "Sales",
+    "parent_object": [
+     "companies"
+    ],
+    "workspace_access": "full-access",
+    "workspace_member_access": [
+     {
+      "level": "full-access",
+      "workspace_member_id": "67af46e4-a450-4fee-a1d1-39729b3af771"
+     }
+    ]
+   }
+}
\ No newline at end of file
diff --git a/providers/attio/test/write_notes.json b/providers/attio/test/write_notes.json
new file mode 100644
index 00000000..514a8bf0
--- /dev/null
+++ b/providers/attio/test/write_notes.json
@@ -0,0 +1,17 @@
+{
+    "data": {
+    "content_plaintext": "summary",
+    "created_at": "2024-10-04T10:03:03.347000000Z",
+    "created_by_actor": {
+     "id": "00bcd19c-9a89-467f-bf61-4e1a7f8b8754",
+     "type": "api-token"
+    },
+    "id": {
+     "note_id": "126e58a5-5e3f-4644-89ff-6474e97fcecd",
+     "workspace_id": "0d4d7fa2-d6e8-4a61-a7dc-e178405ff3c6"
+    },
+    "parent_object": "companies",
+    "parent_record_id": "ec902ed9-aab7-4347-8e26-dca240ffba08",
+    "title": "Call summary"
+   }
+}
\ No newline at end of file
diff --git a/providers/attio/test/write_objects.json b/providers/attio/test/write_objects.json
new file mode 100644
index 00000000..49b3ab08
--- /dev/null
+++ b/providers/attio/test/write_objects.json
@@ -0,0 +1,12 @@
+{
+    "data": {
+  "api_slug": "deal",
+  "created_at": "2024-10-04T10:01:42.878000000Z",        
+  "id": {
+   "object_id": "bf012982-06a9-47f7-9e87-07dc4945d502",  
+   "workspace_id": "0d4d7fa2-d6e8-4a61-a7dc-e178405ff3c6"
+  },
+  "plural_noun": "Dealss",
+  "singular_noun": "Deals"
+ }
+}
\ No newline at end of file
diff --git a/providers/attio/test/write_tasks.json b/providers/attio/test/write_tasks.json
new file mode 100644
index 00000000..26f6107d
--- /dev/null
+++ b/providers/attio/test/write_tasks.json
@@ -0,0 +1,28 @@
+{
+    "data": {
+    "assignees": [
+     {
+      "referenced_actor_id": "67af46e4-a450-4fee-a1d1-39729b3af771",
+      "referenced_actor_type": "workspace-member"
+     }
+    ],
+    "content_plaintext": "view summary",
+    "created_at": "2024-10-04T10:03:04.216000000Z",
+    "created_by_actor": {
+     "id": "00bcd19c-9a89-467f-bf61-4e1a7f8b8754",
+     "type": "api-token"
+    },
+    "deadline_at": "2023-10-04T15:00:00.000000000Z",
+    "id": {
+     "task_id": "b38142c7-00f6-4d92-813e-7b0f689a5873",
+     "workspace_id": "0d4d7fa2-d6e8-4a61-a7dc-e178405ff3c6"
+    },
+    "is_completed": false,
+    "linked_records": [
+     {
+      "target_object_id": "f4df082c-b46e-43e4-a747-f7918b487f44",
+      "target_record_id": "ec902ed9-aab7-4347-8e26-dca240ffba08"
+     }
+    ]
+   }
+}
\ No newline at end of file
diff --git a/providers/attio/test/write_webhook.json b/providers/attio/test/write_webhook.json
new file mode 100644
index 00000000..a42f6c91
--- /dev/null
+++ b/providers/attio/test/write_webhook.json
@@ -0,0 +1,18 @@
+{
+    "data": {
+    "created_at": "2024-10-04T10:05:01.173000000Z",
+    "id": {
+     "webhook_id": "7e5209b8-bd4e-41d9-bbcd-2f9bab7d4030",
+     "workspace_id": "0d4d7fa2-d6e8-4a61-a7dc-e178405ff3c6"
+    },
+    "secret": "edc47acc750ba7af0b1663f606b3a3a5f6fb0a3c37f7b679c9416318b523d968",
+    "status": "active",
+    "subscriptions": [
+     {
+      "event_type": "note.deleted",
+      "filter": null
+     }
+    ],
+    "target_url": "https://f87a-117-216-131-16.ngrok-free.app"
+   }
+}
\ No newline at end of file
diff --git a/providers/attio/write_test.go b/providers/attio/write_test.go
new file mode 100644
index 00000000..ce44e6e7
--- /dev/null
+++ b/providers/attio/write_test.go
@@ -0,0 +1,377 @@
+// nolint
+package attio
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/amp-labs/connectors"
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/test/utils/mockutils"
+	"github.com/amp-labs/connectors/test/utils/mockutils/mockserver"
+	"github.com/amp-labs/connectors/test/utils/testroutines"
+	"github.com/amp-labs/connectors/test/utils/testutils"
+)
+
+func TestWrite(t *testing.T) { // nolint:funlen,gocognit,cyclop
+	t.Parallel()
+
+	responseObject := testutils.DataFromFile(t, "write_objects.json")
+	listResponse := testutils.DataFromFile(t, "write_lists.json")
+	notesresponse := testutils.DataFromFile(t, "write_notes.json")
+	tasksResponse := testutils.DataFromFile(t, "write_tasks.json")
+	webhookResponse := testutils.DataFromFile(t, "write_webhook.json")
+	tests := []testroutines.Write{
+		{
+			Name:         "Write object must be included",
+			Server:       mockserver.Dummy(),
+			ExpectedErrs: []error{common.ErrMissingObjects},
+		},
+		{
+			Name:         "Write needs data payload",
+			Input:        common.WriteParams{ObjectName: "objects"},
+			Server:       mockserver.Dummy(),
+			ExpectedErrs: []error{common.ErrMissingRecordData},
+		},
+		{
+			Name:     "Unknown object name is not supported",
+			Input:    common.WriteParams{ObjectName: "attributes", RecordData: "dummy"},
+			Server:   mockserver.Dummy(),
+			Expected: nil,
+			ExpectedErrs: []error{
+				common.ErrOperationNotSupportedForObject,
+			},
+		},
+		{
+			Name:  "Create objects as POST",
+			Input: common.WriteParams{ObjectName: "objects", RecordData: "dummy"},
+			Server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				mockutils.RespondToMethod(w, r, "POST", func() {
+					w.WriteHeader(http.StatusOK)
+					_, _ = w.Write(responseObject)
+				})
+			})),
+			Expected: &common.WriteResult{
+				Success:  true,
+				RecordId: "",
+				Errors:   nil,
+				Data: map[string]any{
+					"api_slug":   "deal",
+					"created_at": "2024-10-04T10:01:42.878000000Z",
+					"id": map[string]any{
+						"object_id":    "bf012982-06a9-47f7-9e87-07dc4945d502",
+						"workspace_id": "0d4d7fa2-d6e8-4a61-a7dc-e178405ff3c6",
+					},
+					"plural_noun":   "Dealss",
+					"singular_noun": "Deals",
+				},
+			},
+			ExpectedErrs: nil,
+		},
+		{
+			Name:  "Update objects as PATCH",
+			Input: common.WriteParams{ObjectName: "objects", RecordId: "bf012982-06a9-47f7-9e87-07dc4945d502", RecordData: "dummy"},
+			Server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				mockutils.RespondToMethod(w, r, "PATCH", func() {
+					w.WriteHeader(http.StatusOK)
+					_, _ = w.Write(responseObject)
+				})
+			})),
+			Expected: &common.WriteResult{
+				Success:  true,
+				RecordId: "",
+				Errors:   nil,
+				Data: map[string]any{
+					"api_slug":   "deal",
+					"created_at": "2024-10-04T10:01:42.878000000Z",
+					"id": map[string]any{
+						"object_id":    "bf012982-06a9-47f7-9e87-07dc4945d502",
+						"workspace_id": "0d4d7fa2-d6e8-4a61-a7dc-e178405ff3c6",
+					},
+					"plural_noun":   "Dealss",
+					"singular_noun": "Deals",
+				},
+			},
+			ExpectedErrs: nil,
+		},
+		{
+			Name:  "Create lists as POST",
+			Input: common.WriteParams{ObjectName: "lists", RecordData: "dummy"},
+			Server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				mockutils.RespondToMethod(w, r, "POST", func() {
+					w.WriteHeader(http.StatusOK)
+					_, _ = w.Write(listResponse)
+				})
+			})),
+			Expected: &common.WriteResult{
+				Success:  true,
+				RecordId: "",
+				Errors:   nil,
+				Data: map[string]any{
+					"api_slug":   "sales_investing",
+					"created_at": "2024-10-04T10:03:02.070000000Z",
+					"created_by_actor": map[string]any{
+						"id":   "00bcd19c-9a89-467f-bf61-4e1a7f8b8754",
+						"type": "api-token",
+					},
+					"id": map[string]any{
+						"list_id":      "e09a041c-0555-4bb2-8f6e-997bfc9b54e8",
+						"workspace_id": "0d4d7fa2-d6e8-4a61-a7dc-e178405ff3c6",
+					},
+					"name": "Sales",
+					"parent_object": []any{
+						"companies",
+					},
+					"workspace_access": "full-access",
+					"workspace_member_access": []any{
+						map[string]any{
+							"level":               "full-access",
+							"workspace_member_id": "67af46e4-a450-4fee-a1d1-39729b3af771",
+						},
+					},
+				},
+			},
+			ExpectedErrs: nil,
+		},
+		{
+			Name:  "Update lists as PATCH",
+			Input: common.WriteParams{ObjectName: "lists", RecordId: "e09a041c-0555-4bb2-8f6e-997bfc9b54e8", RecordData: "dummy"},
+			Server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				mockutils.RespondToMethod(w, r, "PATCH", func() {
+					w.WriteHeader(http.StatusOK)
+					_, _ = w.Write(listResponse)
+				})
+			})),
+			Expected: &common.WriteResult{
+				Success:  true,
+				RecordId: "",
+				Errors:   nil,
+				Data: map[string]any{
+					"api_slug":   "sales_investing",
+					"created_at": "2024-10-04T10:03:02.070000000Z",
+					"created_by_actor": map[string]any{
+						"id":   "00bcd19c-9a89-467f-bf61-4e1a7f8b8754",
+						"type": "api-token",
+					},
+					"id": map[string]any{
+						"list_id":      "e09a041c-0555-4bb2-8f6e-997bfc9b54e8",
+						"workspace_id": "0d4d7fa2-d6e8-4a61-a7dc-e178405ff3c6",
+					},
+					"name": "Sales",
+					"parent_object": []any{
+						"companies",
+					},
+					"workspace_access": "full-access",
+					"workspace_member_access": []any{
+						map[string]any{
+							"level":               "full-access",
+							"workspace_member_id": "67af46e4-a450-4fee-a1d1-39729b3af771",
+						},
+					},
+				},
+			},
+			ExpectedErrs: nil,
+		},
+		{
+			Name:  "Create notes as POST",
+			Input: common.WriteParams{ObjectName: "notes", RecordData: "dummy"},
+			Server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				mockutils.RespondToMethod(w, r, "POST", func() {
+					w.WriteHeader(http.StatusOK)
+					_, _ = w.Write(notesresponse)
+				})
+			})),
+			Expected: &common.WriteResult{
+				Success:  true,
+				RecordId: "",
+				Errors:   nil,
+				Data: map[string]any{
+					"content_plaintext": "summary",
+					"created_at":        "2024-10-04T10:03:03.347000000Z",
+					"created_by_actor": map[string]any{
+						"id":   "00bcd19c-9a89-467f-bf61-4e1a7f8b8754",
+						"type": "api-token",
+					},
+					"id": map[string]any{
+						"note_id":      "126e58a5-5e3f-4644-89ff-6474e97fcecd",
+						"workspace_id": "0d4d7fa2-d6e8-4a61-a7dc-e178405ff3c6",
+					},
+					"parent_object":    "companies",
+					"parent_record_id": "ec902ed9-aab7-4347-8e26-dca240ffba08",
+					"title":            "Call summary",
+				},
+			},
+			ExpectedErrs: nil,
+		},
+		{
+			Name:  "Create tasks as POST",
+			Input: common.WriteParams{ObjectName: "tasks", RecordData: "dummy"},
+			Server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				mockutils.RespondToMethod(w, r, "POST", func() {
+					w.WriteHeader(http.StatusOK)
+					_, _ = w.Write(tasksResponse)
+				})
+			})),
+			Expected: &common.WriteResult{
+				Success:  true,
+				RecordId: "",
+				Errors:   nil,
+				Data: map[string]any{
+					"assignees": []any{
+						map[string]any{
+							"referenced_actor_id":   "67af46e4-a450-4fee-a1d1-39729b3af771",
+							"referenced_actor_type": "workspace-member",
+						},
+					},
+					"content_plaintext": "view summary",
+					"created_at":        "2024-10-04T10:03:04.216000000Z",
+					"created_by_actor": map[string]any{
+						"id":   "00bcd19c-9a89-467f-bf61-4e1a7f8b8754",
+						"type": "api-token",
+					},
+					"deadline_at": "2023-10-04T15:00:00.000000000Z",
+					"id": map[string]any{
+						"task_id":      "b38142c7-00f6-4d92-813e-7b0f689a5873",
+						"workspace_id": "0d4d7fa2-d6e8-4a61-a7dc-e178405ff3c6",
+					},
+					"is_completed": false,
+					"linked_records": []any{
+						map[string]any{
+							"target_object_id": "f4df082c-b46e-43e4-a747-f7918b487f44",
+							"target_record_id": "ec902ed9-aab7-4347-8e26-dca240ffba08",
+						},
+					},
+				},
+			},
+			ExpectedErrs: nil,
+		},
+		{
+			Name:  "Update tasks as PATCH",
+			Input: common.WriteParams{ObjectName: "tasks", RecordId: "bf012982-06a9-47f7-9e87-07dc4945d502", RecordData: "dummy"},
+			Server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				mockutils.RespondToMethod(w, r, "PATCH", func() {
+					w.WriteHeader(http.StatusOK)
+					_, _ = w.Write(tasksResponse)
+				})
+			})),
+			Expected: &common.WriteResult{
+				Success:  true,
+				RecordId: "",
+				Errors:   nil,
+				Data: map[string]any{
+					"assignees": []any{
+						map[string]any{
+							"referenced_actor_id":   "67af46e4-a450-4fee-a1d1-39729b3af771",
+							"referenced_actor_type": "workspace-member",
+						},
+					},
+					"content_plaintext": "view summary",
+					"created_at":        "2024-10-04T10:03:04.216000000Z",
+					"created_by_actor": map[string]any{
+						"id":   "00bcd19c-9a89-467f-bf61-4e1a7f8b8754",
+						"type": "api-token",
+					},
+					"deadline_at": "2023-10-04T15:00:00.000000000Z",
+					"id": map[string]any{
+						"task_id":      "b38142c7-00f6-4d92-813e-7b0f689a5873",
+						"workspace_id": "0d4d7fa2-d6e8-4a61-a7dc-e178405ff3c6",
+					},
+					"is_completed": false,
+					"linked_records": []any{
+						map[string]any{
+							"target_object_id": "f4df082c-b46e-43e4-a747-f7918b487f44",
+							"target_record_id": "ec902ed9-aab7-4347-8e26-dca240ffba08",
+						},
+					},
+				},
+			},
+			ExpectedErrs: nil,
+		},
+		{
+			Name:  "Create webhooks as POST",
+			Input: common.WriteParams{ObjectName: "webhooks", RecordData: "dummy"},
+			Server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				mockutils.RespondToMethod(w, r, "POST", func() {
+					w.WriteHeader(http.StatusOK)
+					_, _ = w.Write(webhookResponse)
+				})
+			})),
+			Expected: &common.WriteResult{
+				Success:  true,
+				RecordId: "",
+				Errors:   nil,
+				Data: map[string]any{
+					"created_at": "2024-10-04T10:05:01.173000000Z",
+					"id": map[string]any{
+						"webhook_id":   "7e5209b8-bd4e-41d9-bbcd-2f9bab7d4030",
+						"workspace_id": "0d4d7fa2-d6e8-4a61-a7dc-e178405ff3c6",
+					},
+					"secret": "edc47acc750ba7af0b1663f606b3a3a5f6fb0a3c37f7b679c9416318b523d968",
+					"status": "active",
+					"subscriptions": []any{
+						map[string]any{
+							"event_type": "note.deleted",
+							"filter":     nil,
+						},
+					},
+					"target_url": "https://f87a-117-216-131-16.ngrok-free.app",
+				},
+			},
+			ExpectedErrs: nil,
+		},
+		{
+			Name:  "Update webhooks as PATCH",
+			Input: common.WriteParams{ObjectName: "webhooks", RecordId: "7e5209b8-bd4e-41d9-bbcd-2f9bab7d4030", RecordData: "dummy"},
+			Server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				mockutils.RespondToMethod(w, r, "PATCH", func() {
+					w.WriteHeader(http.StatusOK)
+					_, _ = w.Write(webhookResponse)
+				})
+			})),
+			Expected: &common.WriteResult{
+				Success:  true,
+				RecordId: "",
+				Errors:   nil,
+				Data: map[string]any{
+					"created_at": "2024-10-04T10:05:01.173000000Z",
+					"id": map[string]any{
+						"webhook_id":   "7e5209b8-bd4e-41d9-bbcd-2f9bab7d4030",
+						"workspace_id": "0d4d7fa2-d6e8-4a61-a7dc-e178405ff3c6",
+					},
+					"secret": "edc47acc750ba7af0b1663f606b3a3a5f6fb0a3c37f7b679c9416318b523d968",
+					"status": "active",
+					"subscriptions": []any{
+						map[string]any{
+							"event_type": "note.deleted",
+							"filter":     nil,
+						},
+					},
+					"target_url": "https://f87a-117-216-131-16.ngrok-free.app",
+				},
+			},
+			ExpectedErrs: nil,
+		},
+	}
+
+	for _, tt := range tests {
+		// nolint:varnamelen
+		tt := tt
+		t.Run(tt.Name, func(t *testing.T) {
+			t.Parallel()
+
+			tt.Run(t, func() (connectors.WriteConnector, error) {
+				return constructTestConnector(tt.Server.URL)
+			})
+		})
+	}
+}
diff --git a/test/attio/connector.go b/test/attio/connector.go
new file mode 100644
index 00000000..50ddf257
--- /dev/null
+++ b/test/attio/connector.go
@@ -0,0 +1,41 @@
+package attio
+
+import (
+	"context"
+	"net/http"
+
+	"github.com/amp-labs/connectors/common/scanning/credscanning"
+	"github.com/amp-labs/connectors/providers"
+	"github.com/amp-labs/connectors/providers/attio"
+	"github.com/amp-labs/connectors/test/utils"
+	"golang.org/x/oauth2"
+)
+
+func GetAttioConnector(ctx context.Context) *attio.Connector {
+	filePath := credscanning.LoadPath(providers.Attio)
+	reader := utils.MustCreateProvCredJSON(filePath, true, false)
+
+	conn, err := attio.NewConnector(
+		attio.WithClient(ctx, http.DefaultClient, getConfig(reader), reader.GetOauthToken()),
+	)
+	if err != nil {
+		utils.Fail("error creating Attio connector", "error", err)
+	}
+
+	return conn
+}
+
+func getConfig(reader *credscanning.ProviderCredentials) *oauth2.Config {
+	cfg := &oauth2.Config{
+		ClientID:     reader.Get(credscanning.Fields.ClientId),
+		ClientSecret: reader.Get(credscanning.Fields.ClientSecret),
+		RedirectURL:  "https://dev-api.withampersand.com/callbacks/v1/oauth",
+		Endpoint: oauth2.Endpoint{
+			AuthURL:   "https://app.attio.com/authorize",
+			TokenURL:  "https://app.attio.com/oauth/token",
+			AuthStyle: oauth2.AuthStyleInParams,
+		},
+	}
+
+	return cfg
+}
diff --git a/test/attio/metadata/metadata.go b/test/attio/metadata/metadata.go
new file mode 100644
index 00000000..ea5d8c5a
--- /dev/null
+++ b/test/attio/metadata/metadata.go
@@ -0,0 +1,24 @@
+package main
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/amp-labs/connectors/test/attio"
+	"github.com/amp-labs/connectors/test/utils"
+)
+
+func main() {
+	ctx := context.Background()
+
+	conn := attio.GetAttioConnector(ctx)
+
+	m, err := conn.ListObjectMetadata(ctx, []string{"objects", "lists", "workspace_members", "webhooks", "tasks", "notes"})
+	if err != nil {
+		utils.Fail(err.Error())
+	}
+
+	// Print the results.
+	fmt.Println("Results: ", m.Result)
+	fmt.Println("Errors: ", m.Errors)
+}
diff --git a/test/attio/read/main.go b/test/attio/read/main.go
new file mode 100644
index 00000000..0a5033f0
--- /dev/null
+++ b/test/attio/read/main.go
@@ -0,0 +1,192 @@
+package main
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"log"
+	"os"
+
+	"github.com/amp-labs/connectors"
+	"github.com/amp-labs/connectors/common"
+	ap "github.com/amp-labs/connectors/providers/attio"
+	"github.com/amp-labs/connectors/test/attio"
+)
+
+func main() {
+	os.Exit(MainFn())
+}
+
+func MainFn() int {
+	conn := attio.GetAttioConnector(context.Background())
+
+	err := testReadObjects(context.Background(), conn)
+	if err != nil {
+		return 1
+	}
+
+	err = testReadLists(context.Background(), conn)
+	if err != nil {
+		return 1
+	}
+
+	err = testReadWorkspacemembers(context.Background(), conn)
+	if err != nil {
+		return 1
+	}
+
+	err = testReadWebhooks(context.Background(), conn)
+	if err != nil {
+		return 1
+	}
+
+	err = testReadTasks(context.Background(), conn)
+	if err != nil {
+		return 1
+	}
+
+	err = testReadNotes(context.Background(), conn)
+	if err != nil {
+		return 1
+	}
+
+	return 0
+}
+
+func testReadObjects(ctx context.Context, conn *ap.Connector) error {
+	params := common.ReadParams{
+		ObjectName: "objects",
+		Fields:     connectors.Fields(""),
+	}
+
+	res, err := conn.Read(ctx, params)
+	if err != nil {
+		log.Fatal(err.Error())
+	}
+
+	// Print the results.
+	jsonStr, err := json.MarshalIndent(res, "", "  ")
+	if err != nil {
+		return fmt.Errorf("error marshalling JSON: %w", err)
+	}
+
+	_, _ = os.Stdout.Write(jsonStr)
+	_, _ = os.Stdout.WriteString("\n")
+
+	return nil
+}
+
+func testReadLists(ctx context.Context, conn *ap.Connector) error {
+	params := common.ReadParams{
+		ObjectName: "lists",
+		Fields:     connectors.Fields(""),
+	}
+
+	res, err := conn.Read(ctx, params)
+	if err != nil {
+		log.Fatal(err.Error())
+	}
+
+	// Print the results.
+	jsonStr, err := json.MarshalIndent(res, "", "  ")
+	if err != nil {
+		return fmt.Errorf("error marshalling JSON: %w", err)
+	}
+
+	_, _ = os.Stdout.Write(jsonStr)
+	_, _ = os.Stdout.WriteString("\n")
+
+	return nil
+}
+
+func testReadWorkspacemembers(ctx context.Context, conn *ap.Connector) error {
+	params := common.ReadParams{
+		ObjectName: "workspace_members",
+		Fields:     connectors.Fields(""),
+	}
+
+	res, err := conn.Read(ctx, params)
+	if err != nil {
+		log.Fatal(err.Error())
+	}
+
+	// Print the results.
+	jsonStr, err := json.MarshalIndent(res, "", "  ")
+	if err != nil {
+		return fmt.Errorf("error marshalling JSON: %w", err)
+	}
+
+	_, _ = os.Stdout.Write(jsonStr)
+	_, _ = os.Stdout.WriteString("\n")
+
+	return nil
+}
+
+func testReadWebhooks(ctx context.Context, conn *ap.Connector) error {
+	params := common.ReadParams{
+		ObjectName: "webhooks",
+		Fields:     connectors.Fields(""),
+	}
+
+	res, err := conn.Read(ctx, params)
+	if err != nil {
+		log.Fatal(err.Error())
+	}
+
+	// Print the results.
+	jsonStr, err := json.MarshalIndent(res, "", "  ")
+	if err != nil {
+		return fmt.Errorf("error marshalling JSON: %w", err)
+	}
+
+	_, _ = os.Stdout.Write(jsonStr)
+	_, _ = os.Stdout.WriteString("\n")
+
+	return nil
+}
+
+func testReadTasks(ctx context.Context, conn *ap.Connector) error {
+	params := common.ReadParams{
+		ObjectName: "tasks",
+		Fields:     connectors.Fields(""),
+	}
+
+	res, err := conn.Read(ctx, params)
+	if err != nil {
+		log.Fatal(err.Error())
+	}
+
+	// Print the results.
+	jsonStr, err := json.MarshalIndent(res, "", "  ")
+	if err != nil {
+		return fmt.Errorf("error marshalling JSON: %w", err)
+	}
+
+	_, _ = os.Stdout.Write(jsonStr)
+	_, _ = os.Stdout.WriteString("\n")
+
+	return nil
+}
+
+func testReadNotes(ctx context.Context, conn *ap.Connector) error {
+	params := common.ReadParams{
+		ObjectName: "notes",
+		Fields:     connectors.Fields(""),
+	}
+
+	res, err := conn.Read(ctx, params)
+	if err != nil {
+		log.Fatal(err.Error())
+	}
+
+	// Print the results.
+	jsonStr, err := json.MarshalIndent(res, "", "  ")
+	if err != nil {
+		return fmt.Errorf("error marshalling JSON: %w", err)
+	}
+
+	_, _ = os.Stdout.Write(jsonStr)
+	_, _ = os.Stdout.WriteString("\n")
+
+	return nil
+}
diff --git a/test/attio/write/main.go b/test/attio/write/main.go
new file mode 100644
index 00000000..828c1ecb
--- /dev/null
+++ b/test/attio/write/main.go
@@ -0,0 +1,352 @@
+package main
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"log/slog"
+	"os"
+
+	"github.com/amp-labs/connectors/common"
+	ap "github.com/amp-labs/connectors/providers/attio"
+	"github.com/amp-labs/connectors/test/attio"
+)
+
+const (
+	workspaceMemberID = "67af46e4-a450-4fee-a1d1-39729b3af771"
+	recordId          = "ec902ed9-aab7-4347-8e26-dca240ffba08"
+)
+
+func main() {
+	os.Exit(MainFn())
+}
+
+func MainFn() int {
+	ctx := context.Background()
+
+	err := testObjects(ctx)
+	if err != nil {
+		return 1
+	}
+
+	err = testLists(ctx)
+	if err != nil {
+		return 1
+	}
+
+	err = testNotes(ctx)
+	if err != nil {
+		return 1
+	}
+
+	err = testTasks(ctx)
+	if err != nil {
+		return 1
+	}
+
+	err = testWebhooks(ctx)
+	if err != nil {
+		return 1
+	}
+
+	return 0
+}
+
+func testObjects(ctx context.Context) error {
+	conn := attio.GetAttioConnector(ctx)
+
+	slog.Info("Creating the object")
+
+	params := common.WriteParams{
+		ObjectName: "objects",
+		RecordData: map[string]interface{}{
+			"data": map[string]string{
+				"api_slug":      "deal",
+				"singular_noun": "Deals",
+				"plural_noun":   "Dealss",
+			},
+		},
+		RecordId: "",
+	}
+
+	writeRes, err := Write(ctx, conn, params)
+	if err != nil {
+		fmt.Println("ERR: ", err)
+
+		return err
+	}
+
+	if err := constructResponse(writeRes); err != nil {
+		return err
+	}
+
+	slog.Info("Updating the object")
+	updateparams := common.WriteParams{
+		ObjectName: "objects",
+		RecordData: map[string]interface{}{
+			"data": map[string]string{
+				"singular_noun": "Deal",
+			},
+		},
+		RecordId: writeRes.Data["id"].(map[string]interface{})["object_id"].(string),
+	}
+
+	updateres, err := Write(ctx, conn, updateparams)
+	if err != nil {
+		fmt.Println("ERR: ", err)
+
+		return err
+	}
+
+	if err := constructResponse(updateres); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func testLists(ctx context.Context) error {
+	conn := attio.GetAttioConnector(ctx)
+
+	slog.Info("Creating the list")
+
+	writeParams := common.WriteParams{
+		ObjectName: "lists",
+		RecordData: map[string]any{
+			"data": map[string]interface{}{
+				"workspace_access": "full-access",
+				"name":             "Marketing",
+				"api_slug":         "marketing_1",
+				"parent_object":    "companies",
+				"workspace_member_access": []map[string]string{
+					{
+						"workspace_member_id": workspaceMemberID,
+						"level":               "full-access",
+					},
+				},
+			},
+		},
+		RecordId: "",
+	}
+
+	writeRes, err := Write(ctx, conn, writeParams)
+	if err != nil {
+		fmt.Println("ERR: ", err)
+
+		return err
+	}
+
+	if err := constructResponse(writeRes); err != nil {
+		return err
+	}
+
+	slog.Info("Updating the list")
+	updateParams := common.WriteParams{
+		ObjectName: "lists",
+		RecordData: map[string]any{
+			"data": map[string]interface{}{
+				"name": "Sales",
+			},
+		},
+		RecordId: writeRes.Data["id"].(map[string]interface{})["list_id"].(string),
+	}
+
+	writeres, err := Write(ctx, conn, updateParams)
+	if err != nil {
+		fmt.Println("ERR: ", err)
+
+		return err
+	}
+
+	if err := constructResponse(writeres); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func testNotes(ctx context.Context) error {
+	conn := attio.GetAttioConnector(ctx)
+
+	slog.Info("Creating the notes")
+
+	writeParams := common.WriteParams{
+		ObjectName: "notes",
+		RecordData: map[string]any{
+			"data": map[string]string{
+				"format":           "plaintext",
+				"parent_object":    "companies",
+				"parent_record_id": recordId,
+				"title":            "Call summary",
+				"content":          "summary",
+			},
+		},
+		RecordId: "",
+	}
+
+	writeRes, err := Write(ctx, conn, writeParams)
+	if err != nil {
+		fmt.Println("ERR: ", err)
+
+		return err
+	}
+
+	if err := constructResponse(writeRes); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func testTasks(ctx context.Context) error {
+	conn := attio.GetAttioConnector(ctx)
+
+	slog.Info("Creating the task")
+
+	writeParams := common.WriteParams{
+		ObjectName: "tasks",
+		RecordData: map[string]any{
+			"data": map[string]any{
+				"format":       "plaintext",
+				"is_completed": false,
+				"content":      "view summary",
+				"deadline_at":  "2023-10-04T15:00:00.000000000Z",
+				"linked_records": []map[string]any{
+					{
+						"target_object":    "companies",
+						"target_record_id": recordId,
+					},
+				},
+				"assignees": []map[string]any{
+					{
+						"referenced_actor_type": "workspace-member",
+						"referenced_actor_id":   workspaceMemberID,
+					},
+					{
+						"workspace_member_email_address": "integration.user@withampersand.com",
+					},
+				},
+			},
+		},
+		RecordId: "",
+	}
+
+	writeRes, err := Write(ctx, conn, writeParams)
+	if err != nil {
+		fmt.Println("ERR: ", err)
+
+		return err
+	}
+
+	if err := constructResponse(writeRes); err != nil {
+		return err
+	}
+
+	slog.Info("Updating the tasks")
+	updateParams := common.WriteParams{
+		ObjectName: "tasks",
+		RecordData: map[string]any{
+			"data": map[string]interface{}{
+				"deadline_at": "2023-10-03T14:00:00.000000000Z",
+			},
+		},
+		RecordId: writeRes.Data["id"].(map[string]interface{})["task_id"].(string),
+	}
+
+	writeres, err := Write(ctx, conn, updateParams)
+	if err != nil {
+		fmt.Println("ERR: ", err)
+
+		return err
+	}
+
+	if err := constructResponse(writeres); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func testWebhooks(ctx context.Context) error {
+	conn := attio.GetAttioConnector(ctx)
+
+	slog.Info("Creating the webhooks")
+
+	writeParams := common.WriteParams{
+		ObjectName: "webhooks",
+		RecordData: map[string]any{
+			"data": map[string]any{
+				"target_url": "https://f87a-117-216-131-16.ngrok-free.app",
+				"subscriptions": []map[string]any{
+					{
+						"event_type": "note.deleted",
+						"filter":     nil,
+					},
+				},
+			},
+		},
+		RecordId: "",
+	}
+
+	writeRes, err := Write(ctx, conn, writeParams)
+	if err != nil {
+		fmt.Println("ERR: ", err)
+
+		return err
+	}
+
+	if err := constructResponse(writeRes); err != nil {
+		return err
+	}
+
+	slog.Info("Updating the webhooks")
+	updateParams := common.WriteParams{
+		ObjectName: "webhooks",
+		RecordData: map[string]any{
+			"data": map[string]any{
+				"subscriptions": []map[string]any{
+					{
+						"event_type": "note.created",
+						"filter":     nil,
+					},
+				},
+			},
+		},
+		RecordId: writeRes.Data["id"].(map[string]interface{})["webhook_id"].(string),
+	}
+
+	writeres, err := Write(ctx, conn, updateParams)
+	if err != nil {
+		fmt.Println("ERR: ", err)
+
+		return err
+	}
+
+	if err := constructResponse(writeres); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func Write(ctx context.Context, conn *ap.Connector, payload common.WriteParams) (*common.WriteResult, error) {
+	res, err := conn.Write(ctx, payload)
+	if err != nil {
+		return nil, err
+	}
+
+	return res, nil
+}
+
+// unmarshal the write response.
+func constructResponse(res *common.WriteResult) error {
+	jsonStr, err := json.MarshalIndent(res, "", " ")
+	if err != nil {
+		return fmt.Errorf("error marshalling JSON: %w", err)
+	}
+
+	_, _ = os.Stdout.Write(jsonStr)
+	_, _ = os.Stdout.WriteString("\n")
+
+	return nil
+}