Repository: amp-labs/connectors
Pull request #1836 (2025-07-03 07:03:12)

[CON-1664] feat: Enable read and write : fireflies
# Installation
![image](https://github.com/user-attachments/assets/5018e9e2-2876-4ebe-98aa-221e66ff84e6)

# Conventions
- [x] Connector uses `internal/components`
- [x] Metadata uses V2 metadata format
- [x] Read supports pagination
- [x] Raw response is returned as is, no formatting or flattening is performed.
- [x] Write payloads should accept what `ReadResults.Fields` is returning. Any unnecessary nesting around the input is removed.
- [ ] Provider errors are mapped if non-standard (errors with 200 response code are converted to 4XX)
- [x] Custom fields, if not human readable names, are resolved to readable names.
- [x] Unit tests cover read/write/metadata logic (placed in /tests/<provider>)
- [x] Appropriate object names are used. Objects need to be resources, not actions (`jobs` and not `jobs.list`).
- [ ] Modules are only being added because:
  - [ ] The same base URL cannot be used to make a proxy call to objects in all modules
  - [ ] Different base URLs (drive.google.com vs google.com)
  - [ ] Object name collisions (same object name exists in two or more modules)

# Read
For each read object:
- [x] Insert screenshot of metadata fields from read object installation.
![image](https://github.com/user-attachments/assets/5018e9e2-2876-4ebe-98aa-221e66ff84e6)
- [x] Insert screenshot of Svix destination.
![image](https://github.com/user-attachments/assets/fdbb10fa-dfc6-43e8-a9de-1105b47a6a02)
![image](https://github.com/user-attachments/assets/f6520d69-cbc8-483d-87e3-61ecca319e40)
![image](https://github.com/user-attachments/assets/b6895909-c637-4a57-994c-be41e219990d)
- [x] Screenshot of operations on dashboard
![image](https://github.com/user-attachments/assets/1ffef1da-21c5-4e0c-bde0-a72b2cd24ea8)

# Write
For each write object:
- [x] Insert screenshot of dashboard.
- [x] Insert screenshot of HTTP call.

**For liveMeetings object:**
![image](https://github.com/user-attachments/assets/ca87410d-451f-4dcf-b87e-e3c288b2f6d9)
![image](https://github.com/user-attachments/assets/d99dc49d-fd7d-4a1c-9a0c-a7e2869cd74e)
![image](https://github.com/user-attachments/assets/b46a49d0-cf0a-4ec6-89c3-e9bac5788be0)

**For bites object:**
![image](https://github.com/user-attachments/assets/29171df2-aac6-41e7-9a8e-7c3c3ed3f682)
![image](https://github.com/user-attachments/assets/bc6e6fa7-57fb-4df1-8118-679f6d7186ba)
![image](https://github.com/user-attachments/assets/48d6261a-06b0-45c8-86d4-3dc9a7e591be)

**For mettingTitle:**
![image](https://github.com/user-attachments/assets/2cfb5f43-1fe1-4b88-a3d2-df596eb2b540)
![image](https://github.com/user-attachments/assets/26831625-bb5c-492c-8398-90dc119c3380)
![image](https://github.com/user-attachments/assets/09491afa-82eb-47c9-9b82-9f0aba358b45)

# Examples
Link pull request to testdata repo which contains installation file: https://github.com/amp-labs/testdata/pull/65


Patch:
diff --git a/common/http.go b/common/http.go
index aa1bb002..004b3d25 100644
--- a/common/http.go
+++ b/common/http.go
@@ -14,6 +14,7 @@ import (
 	"strings"
 
 	"github.com/amp-labs/connectors/common/logging"
+	"github.com/google/uuid"
 )
 
 // HeaderMode determines how the header should be applied to the request.
@@ -90,6 +91,16 @@ func (h Headers) ApplyToRequest(req *http.Request) {
 	}
 }
 
+func (h Headers) LogValue() slog.Value {
+	attrs := make([]slog.Attr, 0, len(h))
+
+	for _, header := range h {
+		attrs = append(attrs, slog.String(header.Key, header.Value))
+	}
+
+	return slog.GroupValue(attrs...)
+}
+
 // ErrorHandler allows the caller to inject their own HTTP error handling logic.
 // All non-2xx responses will be passed to the error handler. If the error handler
 // returns nil, then the error is ignored and the caller is responsible for handling
@@ -113,8 +124,8 @@ func (h *HTTPClient) getURL(url string) (string, error) {
 	return getURL(h.Base, url)
 }
 
-// redactSensitiveHeaders redacts sensitive headers from the given headers.
-func redactSensitiveHeaders(hdrs []Header) []Header {
+// redactSensitiveRequestHeaders redacts sensitive headers from the given headers.
+func redactSensitiveRequestHeaders(hdrs []Header) Headers {
 	if hdrs == nil {
 		return nil
 	}
@@ -141,6 +152,25 @@ func redactSensitiveHeaders(hdrs []Header) []Header {
 	return redacted
 }
 
+func redactSensitiveResponseHeaders(hdrs []Header) Headers {
+	if hdrs == nil {
+		return nil
+	}
+
+	redacted := make([]Header, 0, len(hdrs))
+
+	for _, hdr := range hdrs {
+		switch {
+		case strings.EqualFold(hdr.Key, "Set-Cookie"):
+			redacted = append(redacted, Header{Key: hdr.Key, Value: "<redacted>"})
+		default:
+			redacted = append(redacted, hdr)
+		}
+	}
+
+	return redacted
+}
+
 // Get makes a GET request to the given URL and returns the response. 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.
@@ -151,15 +181,6 @@ func (h *HTTPClient) Get(ctx context.Context, url string, headers ...Header) (*h
 		return nil, nil, err
 	}
 
-	if logging.IsVerboseLogging(ctx) {
-		logging.VerboseLogger(ctx).Debug("HTTP request",
-			"method", "GET", "url", fullURL,
-			"headers", redactSensitiveHeaders(headers))
-	} else {
-		logging.Logger(ctx).Debug("HTTP request",
-			"method", "GET", "url", fullURL)
-	}
-
 	// Make the request, get the response body
 	res, body, err := h.httpGet(ctx, fullURL, headers) //nolint:bodyclose
 	if err != nil {
@@ -181,16 +202,6 @@ func (h *HTTPClient) Post(ctx context.Context,
 		return nil, nil, err
 	}
 
-	if logging.IsVerboseLogging(ctx) {
-		logging.VerboseLogger(ctx).Debug("HTTP request",
-			"method", "POST", "url", fullURL,
-			"headers", redactSensitiveHeaders(headers),
-			"bodySize", len(reqBody))
-	} else {
-		logging.Logger(ctx).Debug("HTTP request",
-			"method", "POST", "url", fullURL)
-	}
-
 	// Make the request, get the response body
 	res, body, err := h.httpPost(ctx, fullURL, headers, reqBody) //nolint:bodyclose
 	if err != nil {
@@ -245,15 +256,6 @@ func (h *HTTPClient) Delete(ctx context.Context,
 		return nil, nil, err
 	}
 
-	if logging.IsVerboseLogging(ctx) {
-		logging.VerboseLogger(ctx).Debug("HTTP request",
-			"method", "DELETE", "url", fullURL,
-			"headers", redactSensitiveHeaders(headers))
-	} else {
-		logging.Logger(ctx).Debug("HTTP request",
-			"method", "DELETE", "url", fullURL)
-	}
-
 	// Make the request, get the response body
 	res, body, err := h.httpDelete(ctx, fullURL, headers) //nolint:bodyclose
 	if err != nil {
@@ -264,7 +266,7 @@ func (h *HTTPClient) Delete(ctx context.Context,
 }
 
 // httpGet makes a GET request to the given URL and returns the response & response body.
-func (h *HTTPClient) httpGet(ctx context.Context,
+func (h *HTTPClient) httpGet(ctx context.Context, //nolint:dupl
 	url string, headers []Header,
 ) (*http.Response, []byte, error) {
 	req, err := MakeGetRequest(ctx, url, headers)
@@ -272,11 +274,34 @@ func (h *HTTPClient) httpGet(ctx context.Context,
 		return nil, nil, err
 	}
 
-	return h.sendRequest(req)
+	correlationId := uuid.Must(uuid.NewRandom()).String()
+
+	if logging.IsVerboseLogging(ctx) {
+		logRequestWithoutBody(logging.VerboseLogger(ctx), req, "GET", correlationId, url)
+	} else {
+		logRequestWithoutBody(logging.Logger(ctx), req, "GET", correlationId, url)
+	}
+
+	rsp, body, err := h.sendRequest(req)
+	if err != nil {
+		logging.Logger(ctx).Error("HTTP request failed",
+			"method", "GET", "url", url,
+			"correlationId", correlationId, "error", err)
+
+		return nil, nil, err
+	}
+
+	if logging.IsVerboseLogging(ctx) {
+		logResponseWithBody(logging.VerboseLogger(ctx), rsp, "GET", correlationId, url, body)
+	} else {
+		logResponseWithoutBody(logging.Logger(ctx), rsp, "GET", correlationId, url)
+	}
+
+	return rsp, body, nil
 }
 
 // httpPost makes a POST request to the given URL and returns the response & response body.
-func (h *HTTPClient) httpPost(ctx context.Context, url string,
+func (h *HTTPClient) httpPost(ctx context.Context, url string, //nolint:dupl
 	headers []Header, body []byte,
 ) (*http.Response, []byte, error) {
 	req, err := makePostRequest(ctx, url, headers, body)
@@ -284,11 +309,34 @@ func (h *HTTPClient) httpPost(ctx context.Context, url string,
 		return nil, nil, err
 	}
 
-	return h.sendRequest(req)
+	correlationId := uuid.Must(uuid.NewRandom()).String()
+
+	if logging.IsVerboseLogging(ctx) {
+		logRequestWithBody(logging.VerboseLogger(ctx), req, "POST", correlationId, url, body)
+	} else {
+		logRequestWithoutBody(logging.Logger(ctx), req, "POST", correlationId, url)
+	}
+
+	rsp, body, err := h.sendRequest(req)
+	if err != nil {
+		logging.Logger(ctx).Error("HTTP request failed",
+			"method", "POST", "url", url,
+			"correlationId", correlationId, "error", err)
+
+		return nil, nil, err
+	}
+
+	if logging.IsVerboseLogging(ctx) {
+		logResponseWithBody(logging.VerboseLogger(ctx), rsp, "POST", correlationId, url, body)
+	} else {
+		logResponseWithoutBody(logging.Logger(ctx), rsp, "POST", correlationId, url)
+	}
+
+	return rsp, body, nil
 }
 
 // httpPatch makes a PATCH request to the given URL and returns the response & response body.
-func (h *HTTPClient) httpPatch(ctx context.Context,
+func (h *HTTPClient) httpPatch(ctx context.Context, //nolint:dupl
 	url string, headers []Header, body any,
 ) (*http.Response, []byte, error) {
 	req, err := makePatchRequest(ctx, url, headers, body)
@@ -296,11 +344,45 @@ func (h *HTTPClient) httpPatch(ctx context.Context,
 		return nil, nil, err
 	}
 
-	return h.sendRequest(req)
+	correlationId := uuid.Must(uuid.NewRandom()).String()
+
+	if logging.IsVerboseLogging(ctx) {
+		var serializedBody []byte
+
+		if body != nil {
+			serializedBody, err = json.Marshal(body)
+			if err != nil {
+				logging.Logger(ctx).Error("Failed to serialize request body",
+					"method", "PATCH", "url", url,
+					"correlationId", correlationId, "error", err)
+			}
+		}
+
+		logRequestWithBody(logging.VerboseLogger(ctx), req, "PATCH", correlationId, url, serializedBody)
+	} else {
+		logRequestWithoutBody(logging.Logger(ctx), req, "PATCH", correlationId, url)
+	}
+
+	rsp, rspBody, err := h.sendRequest(req)
+	if err != nil {
+		logging.Logger(ctx).Error("HTTP request failed",
+			"method", "PATCH", "url", url,
+			"correlationId", correlationId, "error", err)
+
+		return nil, nil, err
+	}
+
+	if logging.IsVerboseLogging(ctx) {
+		logResponseWithBody(logging.VerboseLogger(ctx), rsp, "PATCH", correlationId, url, rspBody)
+	} else {
+		logResponseWithoutBody(logging.Logger(ctx), rsp, "PATCH", correlationId, url)
+	}
+
+	return rsp, rspBody, nil
 }
 
 // httpPut makes a PUT request to the given URL and returns the response & response body.
-func (h *HTTPClient) httpPut(ctx context.Context,
+func (h *HTTPClient) httpPut(ctx context.Context, //nolint:dupl
 	url string, headers []Header, body any,
 ) (*http.Response, []byte, error) {
 	req, err := makePutRequest(ctx, url, headers, body)
@@ -308,11 +390,45 @@ func (h *HTTPClient) httpPut(ctx context.Context,
 		return nil, nil, err
 	}
 
-	return h.sendRequest(req)
+	correlationId := uuid.Must(uuid.NewRandom()).String()
+
+	if logging.IsVerboseLogging(ctx) {
+		var serializedBody []byte
+
+		if body != nil {
+			serializedBody, err = json.Marshal(body)
+			if err != nil {
+				logging.Logger(ctx).Error("Failed to serialize request body",
+					"method", "PUT", "url", url,
+					"correlationId", correlationId, "error", err)
+			}
+		}
+
+		logRequestWithBody(logging.VerboseLogger(ctx), req, "PUT", correlationId, url, serializedBody)
+	} else {
+		logRequestWithoutBody(logging.Logger(ctx), req, "PUT", correlationId, url)
+	}
+
+	rsp, rspBody, err := h.sendRequest(req)
+	if err != nil {
+		logging.Logger(ctx).Error("HTTP request failed",
+			"method", "PUT", "url", url,
+			"correlationId", correlationId, "error", err)
+
+		return nil, nil, err
+	}
+
+	if logging.IsVerboseLogging(ctx) {
+		logResponseWithBody(logging.VerboseLogger(ctx), rsp, "PUT", correlationId, url, rspBody)
+	} else {
+		logResponseWithoutBody(logging.Logger(ctx), rsp, "PUT", correlationId, url)
+	}
+
+	return rsp, rspBody, nil
 }
 
 // httpDelete makes a DELETE request to the given URL and returns the response & response body.
-func (h *HTTPClient) httpDelete(ctx context.Context,
+func (h *HTTPClient) httpDelete(ctx context.Context, //nolint:dupl
 	url string, headers []Header,
 ) (*http.Response, []byte, error) {
 	req, err := makeDeleteRequest(ctx, url, headers)
@@ -320,7 +436,30 @@ func (h *HTTPClient) httpDelete(ctx context.Context,
 		return nil, nil, err
 	}
 
-	return h.sendRequest(req)
+	correlationId := uuid.Must(uuid.NewRandom()).String()
+
+	if logging.IsVerboseLogging(ctx) {
+		logRequestWithoutBody(logging.VerboseLogger(ctx), req, "DELETE", correlationId, url)
+	} else {
+		logRequestWithoutBody(logging.Logger(ctx), req, "DELETE", correlationId, url)
+	}
+
+	rsp, rspBody, err := h.sendRequest(req)
+	if err != nil {
+		logging.Logger(ctx).Error("HTTP request failed",
+			"method", "DELETE", "url", url,
+			"correlationId", correlationId, "error", err)
+
+		return nil, nil, err
+	}
+
+	if logging.IsVerboseLogging(ctx) {
+		logResponseWithBody(logging.VerboseLogger(ctx), rsp, "DELETE", correlationId, url, rspBody)
+	} else {
+		logResponseWithoutBody(logging.Logger(ctx), rsp, "DELETE", correlationId, url)
+	}
+
+	return rsp, rspBody, nil
 }
 
 // MakeGetRequest creates a GET request with the given headers.
@@ -397,16 +536,6 @@ func makePatchRequest(ctx context.Context, url string, headers []Header, body an
 
 	req.ContentLength = int64(len(jBody))
 
-	if logging.IsVerboseLogging(ctx) {
-		logging.VerboseLogger(ctx).Debug("HTTP request",
-			"method", "PATCH", "url", url,
-			"headers", redactSensitiveHeaders(headers),
-			"bodySize", len(jBody))
-	} else {
-		logging.Logger(ctx).Debug("HTTP request",
-			"method", "PATCH", "url", url)
-	}
-
 	return AddJSONContentTypeIfNotPresent(addHeaders(req, headers)), nil
 }
 
@@ -425,16 +554,6 @@ func makePutRequest(ctx context.Context, url string, headers []Header, body any)
 
 	req.ContentLength = int64(len(jBody))
 
-	if logging.IsVerboseLogging(ctx) {
-		logging.VerboseLogger(ctx).Debug("HTTP request",
-			"method", "PUT", "url", url,
-			"headers", redactSensitiveHeaders(headers),
-			"bodySize", len(jBody))
-	} else {
-		logging.Logger(ctx).Debug("HTTP request",
-			"method", "PUT", "url", url)
-	}
-
 	return AddJSONContentTypeIfNotPresent(addHeaders(req, headers)), nil
 }
 
@@ -542,6 +661,35 @@ func GetResponseBodyOnce(response *http.Response) []byte {
 	return body
 }
 
+func GetRequestHeaders(request *http.Request) Headers {
+	if request == nil {
+		return nil
+	}
+
+	// Pre-calculate the total number of header values
+	totalValues := 0
+	for _, values := range request.Header {
+		totalValues += len(values)
+	}
+
+	// Corner case: if there are no headers, return nil
+	if totalValues == 0 {
+		return nil
+	}
+
+	// Initialize the headers slice with accurate capacity
+	headers := make(Headers, 0, totalValues)
+
+	// Populate the headers slice
+	for key, values := range request.Header {
+		for _, value := range values {
+			headers = append(headers, Header{Key: key, Value: value})
+		}
+	}
+
+	return headers
+}
+
 func GetResponseHeaders(response *http.Response) Headers {
 	if response == nil {
 		return nil
diff --git a/common/printable.go b/common/printable.go
new file mode 100644
index 00000000..c8cdec52
--- /dev/null
+++ b/common/printable.go
@@ -0,0 +1,541 @@
+package common
+
+import (
+	"bytes"
+	"encoding/base64"
+	"encoding/json"
+	"fmt"
+	"io"
+	"log/slog"
+	"mime"
+	"net/http"
+	"strconv"
+	"strings"
+	"unicode"
+	"unicode/utf8"
+
+	"golang.org/x/net/html/charset"
+)
+
+const truncationLength = 512 * 1024 // 512 KB
+
+func logRequestWithBody(logger *slog.Logger, req *http.Request, method, id, fullURL string, body []byte) {
+	headers := redactSensitiveRequestHeaders(GetRequestHeaders(req))
+
+	logger = logger.With(
+		"method", method,
+		"url", fullURL,
+		"correlationId", id,
+		"headers", headers)
+
+	payload, err := PrintableRequest(req, body)
+	if err != nil {
+		logger.Error("Error creating printable request", "error", err)
+
+		return
+	}
+
+	truncatedBody, err := payload.Truncate(truncationLength)
+	if err != nil {
+		logger.Error("Error truncating request body", "error", err)
+
+		return
+	}
+
+	logger.Debug("HTTP request", "body", truncatedBody)
+}
+
+func logRequestWithoutBody(logger *slog.Logger, req *http.Request, method, id, fullURL string) {
+	headers := redactSensitiveRequestHeaders(GetRequestHeaders(req))
+
+	logger = logger.With(
+		"method", method,
+		"url", fullURL,
+		"correlationId", id,
+		"headers", headers)
+
+	logger.Debug("HTTP request")
+}
+
+func logResponseWithoutBody(logger *slog.Logger, res *http.Response, method, id, fullURL string) {
+	headers := redactSensitiveResponseHeaders(GetResponseHeaders(res))
+
+	logger = logger.With(
+		"method", method,
+		"url", fullURL,
+		"correlationId", id,
+		"headers", headers)
+
+	logger.Debug("HTTP response")
+}
+
+func logResponseWithBody(logger *slog.Logger, res *http.Response, method, id, fullURL string, body []byte) {
+	headers := redactSensitiveResponseHeaders(GetResponseHeaders(res))
+
+	logger = logger.With(
+		"method", method,
+		"url", fullURL,
+		"correlationId", id,
+		"headers", headers)
+
+	payload, err := PrintableResponse(res, body)
+	if err != nil {
+		logger.Error("Error creating printable response", "error", err)
+
+		return
+	}
+
+	truncatedBody, err := payload.Truncate(truncationLength)
+	if err != nil {
+		logger.Error("Error truncating response body", "error", err)
+
+		return
+	}
+
+	logger.Debug("HTTP response", "body", truncatedBody)
+}
+
+// PrintablePayload represents a payload that can be printed or displayed.
+// It contains the content, its length, and whether it is base64 encoded.
+// It also includes a truncated length for cases where the content is too large.
+type PrintablePayload struct {
+	Base64          bool   `json:"base64,omitempty"`
+	Content         string `json:"content"`
+	Length          int64  `json:"length"`
+	TruncatedLength int64  `json:"truncatedLength,omitempty"`
+}
+
+func (p *PrintablePayload) String() string {
+	if p == nil {
+		return "<nil>"
+	}
+
+	if p.IsEmpty() {
+		return ""
+	}
+
+	return p.Content
+}
+
+func (p *PrintablePayload) LogValue() slog.Value {
+	var attrs []slog.Attr
+
+	attrs = append(attrs, slog.String("raw", p.String()))
+
+	isJSON, err := p.IsJSON()
+	if err == nil && isJSON {
+		contentBytes, err := p.GetContentBytes()
+		if err == nil && len(contentBytes) > 0 {
+			var jsonValue any
+			if err := json.Unmarshal(contentBytes, &jsonValue); err != nil {
+				return slog.StringValue(p.String())
+			}
+
+			val := jsonToSlogValue(jsonValue)
+			attrs = append(attrs, slog.Any("json", val))
+		}
+	}
+
+	attrs = append(attrs, slog.Bool("base64", p.IsBase64()))
+	attrs = append(attrs, slog.Int64("size", p.Length))
+
+	if p.IsTruncated() {
+		attrs = append(attrs, slog.Int64("sizeTruncated", p.GetTruncatedLength()))
+	}
+
+	return slog.GroupValue(attrs...)
+}
+
+func jsonToSlogValue(v any) slog.Value { //nolint:cyclop
+	switch value := v.(type) {
+	case map[string]any:
+		attrs := make([]slog.Attr, 0, len(value))
+
+		for k, val := range value {
+			attrs = append(attrs, slog.Attr{
+				Key:   k,
+				Value: jsonToSlogValue(val),
+			})
+		}
+
+		return slog.GroupValue(attrs...)
+	case []any:
+		attrs := make([]slog.Attr, len(value))
+
+		for i, val := range value {
+			attrs[i] = slog.Attr{
+				Key:   strconv.FormatInt(int64(i), 10),
+				Value: jsonToSlogValue(val),
+			}
+		}
+
+		return slog.GroupValue(attrs...)
+	case string:
+		return slog.StringValue(value)
+	case float32:
+		return slog.Float64Value(float64(value)) // use Float64Value for consistency
+	case float64:
+		return slog.Float64Value(value)
+	case int:
+		return slog.Int64Value(int64(value)) // use Int64Value for consistency
+	case int32:
+		return slog.Int64Value(int64(value))
+	case uint32:
+		return slog.Uint64Value(uint64(value))
+	case int64:
+		return slog.Int64Value(value)
+	case uint64:
+		return slog.Uint64Value(value)
+	case bool:
+		return slog.BoolValue(value)
+	case nil:
+		return slog.AnyValue(nil)
+	default:
+		// fallback for unexpected types, or custom structs
+		return slog.AnyValue(value)
+	}
+}
+
+func (p *PrintablePayload) IsEmpty() bool {
+	return p == nil || (p.Content == "" && p.Length == 0)
+}
+
+func (p *PrintablePayload) IsBase64() bool {
+	return p != nil && p.Base64
+}
+
+func (p *PrintablePayload) IsJSON() (bool, error) {
+	if p == nil {
+		return false, nil
+	}
+
+	bts, err := p.GetContentBytes()
+	if err != nil {
+		return false, fmt.Errorf("error getting content bytes: %w", err)
+	}
+
+	return json.Valid(bts), nil
+}
+
+func (p *PrintablePayload) GetContent() string {
+	if p == nil {
+		return ""
+	}
+
+	return p.Content
+}
+
+func (p *PrintablePayload) GetContentBytes() ([]byte, error) {
+	if p == nil {
+		return nil, nil //nolint:nilnil
+	}
+
+	if p.IsBase64() {
+		return base64.StdEncoding.DecodeString(p.Content)
+	}
+
+	return []byte(p.Content), nil
+}
+
+func (p *PrintablePayload) GetLength() int64 {
+	if p == nil {
+		return 0
+	}
+
+	return p.Length
+}
+
+func (p *PrintablePayload) IsTruncated() bool {
+	if p == nil {
+		return false
+	}
+
+	return p.GetTruncatedLength() < p.Length
+}
+
+func (p *PrintablePayload) Clone() *PrintablePayload {
+	if p == nil {
+		return nil
+	}
+
+	return &PrintablePayload{
+		Base64:          p.Base64,
+		Content:         p.Content,
+		Length:          p.Length,
+		TruncatedLength: p.TruncatedLength,
+	}
+}
+
+func (p *PrintablePayload) Truncate(size int64) (*PrintablePayload, error) {
+	if p == nil || size < 0 {
+		return nil, nil //nolint:nilnil
+	}
+
+	if size >= p.Length || size >= p.GetTruncatedLength() {
+		// No truncation needed, just return the original
+		return p, nil
+	}
+
+	cloned := p.Clone()
+
+	if p.IsBase64() {
+		bts, err := p.GetContentBytes()
+		if err != nil {
+			return nil, fmt.Errorf("error getting content bytes: %w", err)
+		}
+
+		cloned.TruncatedLength = size
+		truncated := bts[:size]
+		cloned.Content = base64.StdEncoding.EncodeToString(truncated)
+	} else {
+		cloned.Content = cloned.Content[:size]
+
+		// String truncation vs byte truncation may disagree in length (due
+		// to multibyte characters), so we need to ensure the length is correct.
+		cloned.TruncatedLength = int64(len([]byte(cloned.Content)))
+	}
+
+	return cloned, nil
+}
+
+func (p *PrintablePayload) GetTruncatedLength() int64 {
+	if p == nil {
+		return 0
+	}
+
+	if p.TruncatedLength > 0 {
+		return p.TruncatedLength
+	}
+
+	// If not set, use the full length
+	return p.Length
+}
+
+// PrintableRequest creates a PrintablePayload from an HTTP request.
+// The body parameter can be nil, in which case it will read the request body.
+func PrintableRequest(req *http.Request, body []byte) (*PrintablePayload, error) {
+	return getBodyAsPrintable(&requestContentReader{
+		Request:   req,
+		BodyBytes: body,
+	})
+}
+
+// PrintableResponse creates a PrintablePayload from an HTTP response.
+// The body parameter can be nil, in which case it will read the response body.
+func PrintableResponse(resp *http.Response, body []byte) (*PrintablePayload, error) {
+	return getBodyAsPrintable(&responseContentReader{
+		Response:  resp,
+		BodyBytes: body,
+	})
+}
+
+type requestContentReader struct {
+	Request   *http.Request
+	BodyBytes []byte
+}
+
+func (r *requestContentReader) GetBody() io.ReadCloser {
+	if r.Request == nil {
+		return nil
+	}
+
+	if r.BodyBytes != nil {
+		return io.NopCloser(bytes.NewReader(r.BodyBytes))
+	}
+
+	return r.Request.Body
+}
+
+func (r *requestContentReader) GetHeaders() http.Header {
+	if r.Request == nil {
+		return nil
+	}
+
+	return r.Request.Header
+}
+
+func (r *requestContentReader) SetBody(body io.ReadCloser) {
+	if r.Request == nil {
+		return
+	}
+
+	r.BodyBytes = nil // Clear cached bytes if we set a new body
+	r.Request.Body = body
+}
+
+type responseContentReader struct {
+	Response  *http.Response
+	BodyBytes []byte
+}
+
+func (r *responseContentReader) GetBody() io.ReadCloser {
+	if r.Response == nil {
+		return nil
+	}
+
+	if r.BodyBytes != nil {
+		return io.NopCloser(bytes.NewReader(r.BodyBytes))
+	}
+
+	return r.Response.Body
+}
+
+func (r *responseContentReader) GetHeaders() http.Header {
+	if r.Response == nil {
+		return nil
+	}
+
+	return r.Response.Header
+}
+
+func (r *responseContentReader) SetBody(body io.ReadCloser) {
+	if r.Response == nil {
+		return
+	}
+
+	r.Response.Body = body
+	r.BodyBytes = nil
+}
+
+type bodyContentReader interface {
+	GetBody() io.ReadCloser
+	GetHeaders() http.Header
+	SetBody(body io.ReadCloser)
+}
+
+func isPrintableMimeType(mimeType string) bool {
+	// Check if the MIME type is text-based or a known printable format
+	return strings.HasPrefix(mimeType, "text/") ||
+		strings.HasSuffix(mimeType, "+json") ||
+		strings.HasSuffix(mimeType, "+xml") ||
+		mimeType == "application/json" ||
+		mimeType == "application/xml" ||
+		mimeType == "application/javascript" ||
+		mimeType == "application/x-www-form-urlencoded"
+}
+
+func peekBody(bcr bodyContentReader) ([]byte, error) {
+	if bcr == nil || bcr.GetBody() == nil {
+		return nil, nil
+	}
+
+	body := bcr.GetBody()
+
+	// Read the body without closing it
+	var buf bytes.Buffer
+
+	tee := io.TeeReader(body, &buf)
+
+	data, err := io.ReadAll(tee)
+	if err != nil {
+		return nil, fmt.Errorf("error reading response body: %w", err)
+	}
+
+	// Restore the body for further use
+	bcr.SetBody(io.NopCloser(&buf))
+
+	return data, nil
+}
+
+// getBodyAsPrintable does what it can to convert the body of a request or response
+// into a printable format. It checks the MIME type, charset, and content to determine
+// if the content is printable or needs to be base64 encoded. It also handles UTF-8 decoding
+// and checks for printability using a heuristic based on the proportion of printable characters.
+func getBodyAsPrintable(bcr bodyContentReader) (*PrintablePayload, error) { //nolint:funlen,cyclop
+	if bcr == nil || bcr.GetBody() == nil {
+		return nil, nil //nolint:nilnil
+	}
+
+	// Check MIME type
+	contentType := bcr.GetHeaders().Get("Content-Type")
+
+	mimeType, params, err := mime.ParseMediaType(contentType)
+	if err != nil {
+		// If parsing fails, fallback to sniffing the content
+		mimeType = ""
+	}
+
+	charsetStr := strings.ToLower(params["charset"])
+
+	rawData, err := peekBody(bcr)
+	if err != nil {
+		return nil, fmt.Errorf("error peeking response body: %w", err)
+	}
+
+	if mimeType != "" && !isPrintableMimeType(mimeType) {
+		return &PrintablePayload{
+			Base64:  true,
+			Content: base64.StdEncoding.EncodeToString(rawData),
+			Length:  int64(len(rawData)),
+		}, nil
+	}
+
+	// Decode to UTF-8
+	decodedReader, err := charset.NewReaderLabel(charsetStr, bytes.NewReader(rawData))
+	if err != nil {
+		// If charset is unknown or not provided, fallback to UTF-8
+		decodedReader = bytes.NewReader(rawData)
+	}
+
+	decodedData, err := io.ReadAll(decodedReader)
+	if err != nil {
+		return nil, err
+	}
+
+	// Check UTF-8 validity (paranoia)
+	if !utf8.Valid(decodedData) {
+		return &PrintablePayload{
+			Base64:  true,
+			Content: base64.StdEncoding.EncodeToString(rawData),
+			Length:  int64(len(rawData)),
+		}, nil
+	}
+
+	// Check printability (sample max N bytes)
+	const maxCheckLen = 1024
+
+	checkLen := len(decodedData)
+
+	if checkLen > maxCheckLen {
+		checkLen = maxCheckLen
+	}
+
+	sample := decodedData[:checkLen]
+
+	printable := 0
+	total := 0
+
+	for len(sample) > 0 {
+		r, size := utf8.DecodeRune(sample)
+
+		sample = sample[size:]
+		total++
+
+		if unicode.IsPrint(r) || unicode.IsSpace(r) {
+			printable++
+		}
+	}
+
+	if total == 0 {
+		return &PrintablePayload{
+			Content: "",
+			Length:  0,
+		}, nil
+	}
+
+	// Heuristic: 95%+ means printable
+	isPrintable := float64(printable)/float64(total) > 0.95 //nolint:mnd
+
+	if isPrintable {
+		return &PrintablePayload{
+			Content: string(decodedData),
+			Length:  int64(len(decodedData)),
+		}, nil
+	}
+
+	return &PrintablePayload{
+		Base64:  true,
+		Content: base64.StdEncoding.EncodeToString(rawData),
+		Length:  int64(len(rawData)),
+	}, nil
+}
diff --git a/common/stringMap.go b/common/stringMap.go
index 25504f1f..aab927e7 100644
--- a/common/stringMap.go
+++ b/common/stringMap.go
@@ -98,7 +98,7 @@ func (m StringMap) GetString(key string) (string, error) {
 		return "", err
 	}
 
-	return assertType[string](val)
+	return AssertType[string](val)
 }
 
 func (m StringMap) GetBool(key string) (bool, error) {
@@ -107,7 +107,7 @@ func (m StringMap) GetBool(key string) (bool, error) {
 		return false, err
 	}
 
-	return assertType[bool](val)
+	return AssertType[bool](val)
 }
 
 // GetInt extracts an integer from the map.
@@ -164,13 +164,3 @@ func (m StringMap) GetNumber(key string) (float64, error) {
 		return 0, fmt.Errorf("%w: expected a number, but received %T", errFieldTypeMismatch, val)
 	}
 }
-
-//nolint:ireturn
-func assertType[T any](val any) (T, error) {
-	of, ok := val.(T)
-	if !ok {
-		return of, fmt.Errorf("%w: expected type %T, but received %T", errFieldTypeMismatch, of, val)
-	}
-
-	return of, nil
-}
diff --git a/common/type.go b/common/type.go
new file mode 100644
index 00000000..c33885e4
--- /dev/null
+++ b/common/type.go
@@ -0,0 +1,13 @@
+package common
+
+import "fmt"
+
+//nolint:ireturn
+func AssertType[T any](val any) (T, error) {
+	of, ok := val.(T)
+	if !ok {
+		return of, fmt.Errorf("%w: expected type %T, but received %T", errFieldTypeMismatch, of, val)
+	}
+
+	return of, nil
+}
diff --git a/common/types.go b/common/types.go
index 67b2bcea..d3179f9e 100644
--- a/common/types.go
+++ b/common/types.go
@@ -469,13 +469,17 @@ type CollapsedSubscriptionEvent interface {
 	SubscriptionEventList() ([]SubscriptionEvent, error)
 }
 
-// WebhookVerificationParameters is a struct that contains the parameters required to verify a webhook.
-type WebhookVerificationParameters struct {
-	Headers      http.Header
-	Body         []byte
-	URL          string
-	ClientSecret string
-	Method       string
+// WebhookRequest is a struct that contains the request parameters for a webhook.
+type WebhookRequest struct {
+	Headers http.Header
+	Body    []byte
+	URL     string
+	Method  string
+}
+
+// VerificationParams is a struct that contains the parameters specific to the provider.
+type VerificationParams struct {
+	Param any
 }
 
 func inferDeprecatedFieldsMap(fields FieldsMetadata) map[string]string {
diff --git a/connectors.go b/connectors.go
index 26046b46..963684d3 100644
--- a/connectors.go
+++ b/connectors.go
@@ -101,7 +101,14 @@ type WebhookVerifierConnector interface {
 	BatchRecordReaderConnector
 
 	// VerifyWebhookMessage verifies the signature of a webhook message.
-	VerifyWebhookMessage(ctx context.Context, params *common.WebhookVerificationParameters) (bool, error)
+	VerifyWebhookMessage(
+		ctx context.Context,
+		// request is the raw webhook request from the provider.
+		request *common.WebhookRequest,
+		// params is the verification parameters unique to the user.
+		// It is used to verify the signature of the webhook message.
+		params *common.VerificationParams,
+	) (bool, error)
 }
 
 type RegisterSubscribeConnector interface {
diff --git a/go.mod b/go.mod
index bcab0a04..d2446d01 100644
--- a/go.mod
+++ b/go.mod
@@ -26,8 +26,9 @@ require (
 	github.com/spyzhov/ajson v0.9.6
 	github.com/stretchr/testify v1.10.0
 	gitlab.com/c0b/go-ordered-json v0.0.0-20201030195603-febf46534d5a
+	golang.org/x/net v0.39.0
 	golang.org/x/oauth2 v0.30.0
-	golang.org/x/text v0.26.0
+	golang.org/x/text v0.27.0
 )
 
 require (
@@ -74,7 +75,6 @@ require (
 	github.com/subosito/gotenv v1.6.0 // indirect
 	go.uber.org/atomic v1.9.0 // indirect
 	go.uber.org/multierr v1.9.0 // indirect
-	golang.org/x/net v0.39.0 // indirect
 	golang.org/x/sys v0.32.0 // indirect
 	gopkg.in/go-playground/assert.v1 v1.2.1 // indirect
 )
diff --git a/go.sum b/go.sum
index 99a0d1ec..cb1f7d22 100644
--- a/go.sum
+++ b/go.sum
@@ -219,8 +219,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
 golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
 golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
 golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
-golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
-golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
+golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
+golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
 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/internal/datautils/merge.go b/internal/datautils/merge.go
index 6b9687d9..58f62769 100644
--- a/internal/datautils/merge.go
+++ b/internal/datautils/merge.go
@@ -50,3 +50,18 @@ func MergeSets[T comparable](sets ...Set[T]) Set[T] {
 
 	return result
 }
+
+// MergeSlices combines multiple slices of the same type into a single slice.
+func MergeSlices[T any](slices ...[]T) []T {
+	totalLen := 0
+	for _, s := range slices {
+		totalLen += len(s)
+	}
+
+	result := make([]T, 0, totalLen)
+	for _, s := range slices {
+		result = append(result, s...)
+	}
+
+	return result
+}
diff --git a/mock/connector.go b/mock/connector.go
index 2934fa4e..297a2ebf 100644
--- a/mock/connector.go
+++ b/mock/connector.go
@@ -62,9 +62,14 @@ func NewConnector(opts ...Option) (conn *Connector, outErr error) { //nolint:fun
 		) ([]common.ReadResultRow, error) {
 			return nil, fmt.Errorf("%w: %s", ErrNotImplemented, "getRecordsByIds")
 		}),
-		WithVerifyWebhookMessage(func(ctx context.Context, params *common.WebhookVerificationParameters) (bool, error) {
-			return false, fmt.Errorf("%w: %s", ErrNotImplemented, "verifyWebhookMessage")
-		}),
+		WithVerifyWebhookMessage(
+			func(
+				ctx context.Context,
+				request *common.WebhookRequest,
+				params *common.VerificationParams,
+			) (bool, error) {
+				return false, fmt.Errorf("%w: %s", ErrNotImplemented, "verifyWebhookMessage")
+			}),
 		WithRegister(func(
 			ctx context.Context,
 			params common.SubscriptionRegistrationParams,
@@ -189,9 +194,10 @@ func (c *Connector) GetRecordsByIds( //nolint:revive
 
 func (c *Connector) VerifyWebhookMessage(
 	ctx context.Context,
-	params *common.WebhookVerificationParameters,
+	request *common.WebhookRequest,
+	params *common.VerificationParams,
 ) (bool, error) {
-	return c.params.verifyWebhookMessage(ctx, params)
+	return c.params.verifyWebhookMessage(ctx, request, params)
 }
 
 func (c *Connector) Register(
diff --git a/mock/params.go b/mock/params.go
index ed4e7819..dd8af24c 100644
--- a/mock/params.go
+++ b/mock/params.go
@@ -96,7 +96,11 @@ func WithGetRecordsByIds(getRecordsByIds func( //nolint:revive
 
 // WithVerifyWebhookMessage sets the verifyWebhookMessage function for the connector.
 func WithVerifyWebhookMessage(
-	verifyWebhookMessage func(ctx context.Context, params *common.WebhookVerificationParameters) (bool, error),
+	verifyWebhookMessage func(
+		ctx context.Context,
+		request *common.WebhookRequest,
+		params *common.VerificationParams,
+	) (bool, error),
 ) Option {
 	return func(params *parameters) {
 		params.verifyWebhookMessage = verifyWebhookMessage
@@ -205,7 +209,11 @@ type parameters struct {
 		associations []string,
 	) ([]common.ReadResultRow, error)
 
-	verifyWebhookMessage func(ctx context.Context, params *common.WebhookVerificationParameters) (bool, error)
+	verifyWebhookMessage func(
+		ctx context.Context,
+		request *common.WebhookRequest,
+		params *common.VerificationParams,
+	) (bool, error)
 
 	register func(
 		ctx context.Context,
diff --git a/providers/avoma/README.md b/providers/avoma/README.md
index e6f1d4d0..dc48075d 100644
--- a/providers/avoma/README.md
+++ b/providers/avoma/README.md
@@ -15,3 +15,4 @@ Below is an exhaustive list of objects & methods supported on the objects
 | smart_categories       | smart_categories      | read, write   |
 | template               | template              | read, write   |
 | users                  | users                 | read          |
+| transcriptions         | transcriptions        | read          |
diff --git a/providers/avoma/metadata/schemas.json b/providers/avoma/metadata/schemas.json
index c0e27c03..3be39c21 100644
--- a/providers/avoma/metadata/schemas.json
+++ b/providers/avoma/metadata/schemas.json
@@ -627,6 +627,33 @@
             }
           }
         },
+        "transcriptions": {
+          "displayName": "Transcriptions",
+          "path": "/transcriptions/",
+          "responseKey": "results",
+          "fields": {
+            "speakers": {
+              "displayName": "speakers",
+              "valueType": "other",
+              "providerType": "array"
+            },
+            "transcript": {
+              "displayName": "transcript",
+              "valueType": "other",
+              "providerType": "array"
+            },
+            "transcription_vtt_url": {
+              "displayName": "transcription_vtt_url",
+              "valueType": "string",
+              "providerType": "string"
+            },
+            "uuid": {
+              "displayName": "uuid",
+              "valueType": "string",
+              "providerType": "string"
+            }
+          }
+        },
         "users": {
           "displayName": "Users",
           "path": "/users/",
diff --git a/providers/avoma/utils.go b/providers/avoma/utils.go
index 1aeeaf18..13282350 100644
--- a/providers/avoma/utils.go
+++ b/providers/avoma/utils.go
@@ -21,10 +21,8 @@ var avomaQueryEncodingExceptions = map[string]string{ //nolint:gochecknoglobals
 var endpointsWithResultsPath = datautils.NewSet( //nolint:gochecknoglobals
 	"meetings",
 	"calls",
-	"custom_categories",
 	"notes",
 	"scorecard_evaluations",
-	"smart_categories",
 )
 
 func makeNextRecordsURL() common.NextPageFunc {
diff --git a/providers/fireflies.go b/providers/fireflies.go
index 56efff22..ddbd2320 100644
--- a/providers/fireflies.go
+++ b/providers/fireflies.go
@@ -34,9 +34,9 @@ func init() {
 				Delete: false,
 			},
 			Proxy:     true,
-			Read:      false,
+			Read:      true,
 			Subscribe: false,
-			Write:     false,
+			Write:     true,
 		},
 	})
 }
diff --git a/providers/hubspot/associations.go b/providers/hubspot/associations.go
index 5dfaf773..bdce4af4 100644
--- a/providers/hubspot/associations.go
+++ b/providers/hubspot/associations.go
@@ -84,6 +84,11 @@ func (c *Connector) fillAssociations(
 		}
 
 		if len(associations) == 0 {
+			logging.Logger(ctx).Info("no associations found",
+				"fromObject", fromObjName,
+				"toObject", associatedObject,
+				"ids", ids)
+
 			continue
 		}
 
@@ -110,6 +115,9 @@ func (c *Connector) getObjectAssociations( //nolint:cyclop
 	toObject string,
 ) (map[string][]common.Association, error) {
 	if len(fromIDs) == 0 {
+		logging.Logger(ctx).Warn("no IDs provided for associations",
+			"fromObject", fromObject, "toObject", toObject)
+
 		return map[string][]common.Association{}, nil
 	}
 
diff --git a/providers/hubspot/connector.go b/providers/hubspot/connector.go
index 8523aa19..cb451a0e 100644
--- a/providers/hubspot/connector.go
+++ b/providers/hubspot/connector.go
@@ -1,6 +1,7 @@
 package hubspot
 
 import (
+	"github.com/amp-labs/connectors"
 	"github.com/amp-labs/connectors/common"
 	"github.com/amp-labs/connectors/common/paramsbuilder"
 	"github.com/amp-labs/connectors/providers"
@@ -14,6 +15,8 @@ type Connector struct {
 	moduleID   common.ModuleID
 }
 
+var _ connectors.WebhookVerifierConnector = &Connector{}
+
 // NewConnector returns a new Hubspot connector.
 func NewConnector(opts ...Option) (conn *Connector, outErr error) {
 	params, err := paramsbuilder.Apply(parameters{}, opts,
diff --git a/providers/hubspot/parse.go b/providers/hubspot/parse.go
index 6d7eb816..4116be38 100644
--- a/providers/hubspot/parse.go
+++ b/providers/hubspot/parse.go
@@ -5,6 +5,7 @@ import (
 	"strconv"
 
 	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/logging"
 	"github.com/amp-labs/connectors/internal/jsonquery"
 	"github.com/spyzhov/ajson"
 )
@@ -167,6 +168,10 @@ func (c *Connector) getDataMarshaller(
 		}
 
 		if len(associatedObjects) > 0 {
+			logging.Logger(ctx).Debug("fetching associations",
+				"object", objName,
+				"associatedObjects", associatedObjects)
+
 			if err := c.fillAssociations(ctx, objName, &data, associatedObjects); err != nil {
 				return nil, err
 			}
diff --git a/providers/hubspot/subscriptionEvent.go b/providers/hubspot/subscriptionEvent.go
index 9c20d4bb..d55db24b 100644
--- a/providers/hubspot/subscriptionEvent.go
+++ b/providers/hubspot/subscriptionEvent.go
@@ -36,19 +36,28 @@ var (
 	_ common.SubscriptionUpdateEvent = SubscriptionEvent{}
 )
 
+type HubspotVerificationParams struct {
+	ClientSecret string
+}
+
 // VerifyWebhookMessage verifies the signature of a webhook message from Hubspot.
 func (*Connector) VerifyWebhookMessage(
-	_ context.Context, params *common.WebhookVerificationParameters,
+	_ context.Context, request *common.WebhookRequest, params *common.VerificationParams,
 ) (bool, error) {
-	ts := params.Headers.Get(string(xHubspotRequestTimestamp))
+	hsParams, err := common.AssertType[*HubspotVerificationParams](params.Param)
+	if err != nil {
+		return false, fmt.Errorf("invalid verification params: %w", err)
+	}
+
+	ts := request.Headers.Get(string(xHubspotRequestTimestamp))
 
-	rawString := params.Method + params.URL + string(params.Body) + ts
+	rawString := request.Method + request.URL + string(request.Body) + ts
 
-	mac := hmac.New(sha256.New, []byte(params.ClientSecret))
+	mac := hmac.New(sha256.New, []byte(hsParams.ClientSecret))
 	mac.Write([]byte(rawString))
 	expectedMAC := mac.Sum(nil)
 
-	signature := params.Headers.Get(string(xHubspotSignatureV3))
+	signature := request.Headers.Get(string(xHubspotSignatureV3))
 
 	decodedSignature, err := base64.StdEncoding.DecodeString(signature)
 	if err != nil {
diff --git a/providers/salesforce/subscriptionEvent.go b/providers/salesforce/subscriptionEvent.go
index 8aec9997..dc055b85 100644
--- a/providers/salesforce/subscriptionEvent.go
+++ b/providers/salesforce/subscriptionEvent.go
@@ -24,7 +24,11 @@ var (
 	errUnexpectedFieldNameType   = errors.New("unexpected field name type")
 )
 
-func (*Connector) VerifyWebhookMessage(context.Context, *common.WebhookVerificationParameters) (bool, error) {
+func (*Connector) VerifyWebhookMessage(
+	_ context.Context,
+	_ *common.WebhookRequest,
+	_ *common.VerificationParams,
+) (bool, error) {
 	return true, nil
 }
 
diff --git a/providers/zohocrm/subscriptionEvent.go b/providers/zohocrm/subscriptionEvent.go
index ca7de9b1..be543367 100644
--- a/providers/zohocrm/subscriptionEvent.go
+++ b/providers/zohocrm/subscriptionEvent.go
@@ -2,8 +2,10 @@ package zohocrm
 
 import (
 	"context"
+	"encoding/json"
 	"errors"
 	"fmt"
+	"maps"
 	"time"
 
 	"github.com/amp-labs/connectors/common"
@@ -12,19 +14,213 @@ import (
 var (
 	_ common.SubscriptionEvent       = SubscriptionEvent{}
 	_ common.SubscriptionUpdateEvent = SubscriptionEvent{}
+
+	errTypeMismatch = errors.New("type mismatch")
 )
 
 // SubscriptionEvent represents a webhook event from Zoho CRM.
-type SubscriptionEvent map[string]any
+type (
+	SubscriptionEvent      map[string]any
+	ZohoVerificationParams struct {
+		EchoToken string
+	}
+)
 
 // VerifyWebhookMessage verifies the signature of a webhook message from Zoho CRM.
+// Zoho does not send a signature, but instead,
+// they ask us to provide tokens of our choice that they attach to webhook messages
+// they call it "token", in the response body.
 func (*Connector) VerifyWebhookMessage(
-	_ context.Context, params *common.WebhookVerificationParameters,
+	_ context.Context,
+	request *common.WebhookRequest,
+	params *common.VerificationParams,
 ) (bool, error) {
-	return true, nil
+	zohoParams, err := common.AssertType[*ZohoVerificationParams](params.Param)
+	if err != nil {
+		return false, fmt.Errorf("invalid verification params: %w", err)
+	}
+
+	if zohoParams.EchoToken == "" {
+		return false, fmt.Errorf("%w: %s", errFieldNotFound, "echoToken")
+	}
+
+	tokenStr, err := parseToken(request)
+	if err != nil {
+		return false, fmt.Errorf("error parsing token: %w", err)
+	}
+
+	return tokenStr == zohoParams.EchoToken, nil
+}
+
+func parseToken(request *common.WebhookRequest) (string, error) {
+	var body map[string]any
+
+	err := json.Unmarshal(request.Body, &body)
+	if err != nil {
+		return "", err
+	}
+
+	//nolint:varnamelen
+	token, ok := body["token"]
+	if !ok {
+		return "", fmt.Errorf("%w: %s", errFieldNotFound, "token")
+	}
+
+	tokenStr, ok := token.(string)
+	if !ok {
+		return "", fmt.Errorf("%w: %s, expected string, got %T", errTypeMismatch, "token", token)
+	}
+
+	return tokenStr, nil
+}
+
+var (
+	_ common.SubscriptionEvent       = SubscriptionEvent{}
+	_ common.SubscriptionUpdateEvent = SubscriptionEvent{}
+)
+
+type CollapsedSubscriptionEvent map[string]any
+
+var _ common.CollapsedSubscriptionEvent = CollapsedSubscriptionEvent{}
+
+//nolint:funlen
+func (e CollapsedSubscriptionEvent) SubscriptionEventList() ([]common.SubscriptionEvent, error) {
+	/*
+		{
+			"server_time": 1750102639787,
+			"affected_values": [
+				{
+					"record_id": "6756839000000575405",
+					"values": {
+						"Company": "Rangoni Of Test",
+						"Phone": "555-555-1111"
+					}
+				}
+			],
+			"query_params": {},
+			"module": "Leads",
+			"resource_uri": "https://www.zohoapis.com/crm/v2/Leads",
+			"ids": [
+				"6756839000000575405"
+			],
+			"affected_fields": [
+				{
+					"6756839000000575405": [
+						"Company",
+						"Phone"
+					]
+				}
+			],
+			"operation": "update",
+			"channel_id": "1105420521999070702",
+			"token": "c3504777-db15-4332-8286-478a1b5006bc"
+		}
+	*/
+	evts := make([]common.SubscriptionEvent, 0)
+
+	//nolint:varnamelen
+	m := common.StringMap(e)
+
+	affectedValues, err := m.Get("affected_values")
+	if err != nil {
+		return nil, err
+	}
+
+	affectedValuesArr, ok := affectedValues.([]any)
+	if !ok {
+		return nil, fmt.Errorf("%w: %s, expected []any, got %T", errTypeMismatch, "affectedValues", affectedValues)
+	}
+
+	//nolint:varnamelen
+	// "affected_values" from the original event has all the list of records and field values
+	// we use them to convert it to a list of subscription events
+	// This loop will fan out and create list of subscription events for all the records by record id
+	// each record will preserve exact same data structure
+	// except for the "affected_values" and "affected_fields" fields
+	// which will be replaced with an array of one record for each record id
+	for _, affectedValue := range affectedValuesArr {
+		affectedValueMap, ok := affectedValue.(map[string]any)
+		if !ok {
+			return nil, fmt.Errorf("%w: %s, expected map[string]any, got %T", errTypeMismatch, "affectedValue", affectedValue)
+		}
+
+		vm := common.StringMap(affectedValueMap)
+
+		recordId, err := parseIdFromValueMap(vm)
+		if err != nil {
+			return nil, fmt.Errorf("error parsing record id for record %v: %w", affectedValueMap, err)
+		}
+
+		values, err := parseValuesFromValueMap(vm)
+		if err != nil {
+			return nil, fmt.Errorf("error parsing values for record %s: %w", recordId, err)
+		}
+
+		fieldsMap, err := parseFieldsFromValueMap(vm)
+		if err != nil {
+			return nil, fmt.Errorf("error parsing fields for record %s: %w", recordId, err)
+		}
+
+		// clone the original event and replace the affected_values, affected_fields and ids fields
+		// with the new values for the current record
+		subscriptionEvent := maps.Clone(m)
+
+		subscriptionEvent["affected_values"] = []any{values}
+		subscriptionEvent["affected_fields"] = []any{fieldsMap}
+		subscriptionEvent["ids"] = []string{recordId}
+
+		evts = append(evts, SubscriptionEvent(subscriptionEvent))
+	}
+
+	return evts, nil
+}
+
+func parseIdFromValueMap(valueMap common.StringMap) (string, error) {
+	recordId, err := valueMap.GetString("record_id")
+	if err != nil {
+		return "", err
+	}
+
+	return recordId, nil
+}
+
+func parseValuesFromValueMap(valueMap common.StringMap) (map[string]any, error) {
+	values, err := valueMap.Get("values")
+	if err != nil {
+		return nil, err
+	}
+
+	valuesMap, ok := values.(map[string]any)
+	if !ok {
+		return nil, fmt.Errorf("%w: %s, expected map[string]any, got %T", errTypeMismatch, "values", values)
+	}
+
+	return valuesMap, nil
 }
 
-var _ common.SubscriptionEvent = SubscriptionEvent{}
+func parseFieldsFromValueMap(valueMap common.StringMap) (map[string][]string, error) {
+	values, err := valueMap.Get("values")
+	if err != nil {
+		return nil, err
+	}
+
+	vMap, ok := values.(map[string]any)
+	if !ok {
+		return nil, fmt.Errorf("%w: %s, expected map[string]any, got %T", errTypeMismatch, "values", values)
+	}
+
+	fieldsList := make([]string, 0)
+	for field := range vMap {
+		fieldsList = append(fieldsList, field)
+	}
+
+	recordId, err := valueMap.GetString("record_id")
+	if err != nil {
+		return nil, err
+	}
+
+	return map[string][]string{recordId: fieldsList}, nil
+}
 
 // EventType returns the type of event (create, update, delete).
 func (evt SubscriptionEvent) EventType() (common.SubscriptionEventType, error) {
@@ -69,17 +265,11 @@ func (evt SubscriptionEvent) ObjectName() (string, error) {
 	return module, nil
 }
 
+var errNotImplemented = errors.New("not implemented")
+
 // Workspace returns the workspace ID.
 func (evt SubscriptionEvent) Workspace() (string, error) {
-	m := evt.asMap()
-
-	// In Zoho CRM, the channel_id can be used as the workspace identifier
-	channelID, err := m.GetString("channel_id")
-	if err != nil {
-		return "", err
-	}
-
-	return channelID, nil
+	return "", fmt.Errorf("%w: %s", errNotImplemented, "workspace")
 }
 
 // RecordId returns the ID of the record that triggered the event.
@@ -88,20 +278,17 @@ func (evt SubscriptionEvent) RecordId() (string, error) {
 
 	idsAny, err := m.Get("ids")
 	if err != nil {
-		return "", fmt.Errorf("errror getting record id: %w", err) //nolint:err113
+		return "", fmt.Errorf("error getting record id: %w", err) //nolint:err113
 	}
 
 	// convert it to array
-	ids, ok := idsAny.([]any)
+	ids, ok := idsAny.([]string)
 	if !ok || len(ids) == 0 {
-		return "", errors.New("invalid or empty ids array") //nolint:err113
+		return "", fmt.Errorf("%w: %s, expected []string, got %T", errTypeMismatch, "ids", idsAny) //nolint:err113
 	}
 
 	// Get the first ID.
-	id, ok := ids[0].(string)
-	if !ok {
-		return "", errors.New("invalid record id format") //nolint:err113
-	}
+	id := ids[0]
 
 	return id, nil
 }
@@ -127,16 +314,18 @@ func (evt SubscriptionEvent) UpdatedFields() ([]string, error) {
 		return nil, err
 	}
 
-	//nolint:varnamelen
 	affectedFieldsArr, ok := affectedFieldsAny.([]any)
-	if !ok || len(affectedFieldsArr) == 0 {
-		return nil, errors.New("invalid or empty affected_fields array") //nolint:err113
+	if !ok {
+		return nil, fmt.Errorf("%w: %s, expected []any, got %T", errTypeMismatch, "affectedFieldsAny", affectedFieldsAny)
 	}
 
-	// Get the first element which should be a map.
-	firstElement, ok := affectedFieldsArr[0].(map[string]any)
+	affectedFieldsMap, ok := affectedFieldsArr[0].(map[string][]string)
 	if !ok {
-		return nil, errInvalidField
+		return nil, fmt.Errorf("%w: %s, expected map[string][]string, got %T",
+			errTypeMismatch,
+			"affectedFieldsArr",
+			affectedFieldsArr,
+		)
 	}
 
 	recordId, err := evt.RecordId()
@@ -144,70 +333,11 @@ func (evt SubscriptionEvent) UpdatedFields() ([]string, error) {
 		return nil, err
 	}
 
-	fieldsAny, ok := firstElement[recordId].([]any)
-	if !ok {
-		//nolint:err113
-		return nil, fmt.Errorf(
-			"no fields for the record ID %s",
-			recordId)
-	}
-
-	fields := make([]string, 0, len(fieldsAny))
-
-	for _, fieldAny := range fieldsAny {
-		field, ok := fieldAny.(string)
-		if !ok {
-			return nil, errInvalidField
-		}
-
-		fields = append(fields, field)
-	}
+	fields := affectedFieldsMap[recordId]
 
 	return fields, nil
 }
 
-// UpdatedFieldWithvalues returns the fields that were updated in the event along with their values.
-func (evt SubscriptionEvent) UpdatedFieldWithValues() (map[string]string, error) {
-	m := evt.asMap()
-
-	affectedValuesAny, err := m.Get("affected_values")
-	if err != nil {
-		return nil, fmt.Errorf("failed to get affected values: %w", err)
-	}
-
-	affectedValuesArr, ok := affectedValuesAny.([]any) //nolint:varnamelen
-	if !ok {
-		return nil, errInvalidField
-	}
-
-	// get first element
-	firstElement, ok := affectedValuesArr[0].(map[string]any) //nolint:varnamelen
-	if !ok {
-		return nil, errInvalidField
-	}
-
-	affectedValuesRecordID, ok := firstElement["record_id"].(string) //nolint:varnamelen
-	if !ok {
-		return nil, errInvalidField
-	}
-
-	recordID, err := evt.RecordId()
-	if err != nil {
-		return nil, fmt.Errorf("failed to get record id: %w", err)
-	}
-
-	if recordID != affectedValuesRecordID {
-		return nil, errValuesIdMismatch
-	}
-
-	values, ok := firstElement["values"].(map[string]string)
-	if !ok {
-		return nil, errInvalidField
-	}
-
-	return values, nil
-}
-
 // asMap returns the event as a StringMap.
 func (evt SubscriptionEvent) asMap() common.StringMap {
 	return common.StringMap(evt)
diff --git a/providers/zohocrm/vars.go b/providers/zohocrm/vars.go
index 7b5420f8..bb79f5a2 100644
--- a/providers/zohocrm/vars.go
+++ b/providers/zohocrm/vars.go
@@ -20,8 +20,6 @@ const (
 var (
 	errInvalidRequestType    = errors.New("invalid request type")
 	errMissingParams         = errors.New("missing required parameters")
-	errInvalidField          = errors.New("invalid field format")
-	errValuesIdMismatch      = errors.New("record id and affected values record id does not match")
 	errWatchFieldsAll        = errors.New("watch fields all is not supported")
 	errTooManyWatchFields    = errors.New("too many watch fields")
 	errSubscriptionFailed    = errors.New("subscription failed")
diff --git a/scripts/openapi/avoma/metadata/main.go b/scripts/openapi/avoma/metadata/main.go
index d5a8f0b0..fe89cdb3 100644
--- a/scripts/openapi/avoma/metadata/main.go
+++ b/scripts/openapi/avoma/metadata/main.go
@@ -25,7 +25,6 @@ var (
 		"/v1/meetings/{meeting_uuid}/insights/",
 		"/v1/meetings/{uuid}/",
 		"/v1/meetings/{uuid}/drop/",
-		"/v1/transcriptions/",
 		"/v1/recordings/",
 		"/v1/recordings/{uuid}/",
 		"/v1/scorecards/{uuid}/",
@@ -42,6 +41,7 @@ var (
 		"v1/custom_categories/":     "results",
 		"v1/calls/":                 "results",
 		"v1/smart_categories/":      "results",
+		"v1/transcriptions/":        "results",
 	},
 		func(objectName string) (fieldName string) {
 			return objectName
diff --git a/tools/fileconv/api3/models.go b/tools/fileconv/api3/models.go
index 893c7826..d294d0de 100644
--- a/tools/fileconv/api3/models.go
+++ b/tools/fileconv/api3/models.go
@@ -6,6 +6,7 @@ import (
 	"log/slog"
 	"net/http"
 
+	"github.com/amp-labs/connectors/internal/datautils"
 	"github.com/amp-labs/connectors/internal/metadatadef"
 	"github.com/getkin/kin-openapi/openapi3"
 )
@@ -186,9 +187,13 @@ func extractPropertiesArrayType(schema *openapi3.Schema) []Array {
 	definitions := []openapi3.Schemas{
 		schema.Properties,
 	}
-	for _, allOf := range schema.AllOf {
-		// Item schema will likely be inside composite schema
-		definitions = append(definitions, allOf.Value.Properties)
+
+	compositeSchemas := datautils.MergeSlices(schema.AllOf, schema.OneOf, schema.AnyOf)
+	// Item schema will likely be inside composite schema
+	for _, comp := range compositeSchemas {
+		if comp != nil && comp.Value != nil {
+			definitions = append(definitions, comp.Value.Properties)
+		}
 	}
 
 	arrays := make([]Array, 0)


Test patch:
diff --git a/common/scanning/registry_test.go b/common/scanning/registry_test.go
index 5ba281df..1b43aca0 100644
--- a/common/scanning/registry_test.go
+++ b/common/scanning/registry_test.go
@@ -48,6 +48,7 @@ var testPreset = []Reader{
 func TestCredentialOptions(t *testing.T) {
 	t.Parallel()
 
+	//nolint:usetesting
 	if os.Setenv("TEST_ENV_CLIENT_ID", "clientId") != nil {
 		t.Fatal("Error setting environment variable")
 	}
diff --git a/providers/zohocrm/subscriptionEvent_test.go b/providers/zohocrm/subscriptionEvent_test.go
new file mode 100644
index 00000000..c873d24e
--- /dev/null
+++ b/providers/zohocrm/subscriptionEvent_test.go
@@ -0,0 +1,119 @@
+package zohocrm
+
+import (
+	"encoding/json"
+	"testing"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/tools/debug"
+	"gotest.tools/v3/assert"
+)
+
+//nolint:funlen,cyclop
+func TestCollapsedSubscriptionEvent(t *testing.T) {
+	t.Parallel()
+	logger := t.Log
+
+	var evt *CollapsedSubscriptionEvent
+
+	evtStr := `{
+			"server_time": 1750102639787,
+			"affected_values": [
+				{
+					"record_id": "6756839000000575405",
+					"values": {
+						"Company": "Rangoni Of Test",
+						"Phone": "555-555-1111"
+					}
+				}
+			],
+			"query_params": {},
+			"module": "Leads",
+			"resource_uri": "https://www.zohoapis.com/crm/v2/Leads",
+			"ids": [
+				"6756839000000575405"
+			],
+			"affected_fields": [
+				{
+					"6756839000000575405": [
+						"Company",
+						"Phone"
+					]
+				}
+			],
+			"operation": "update",
+			"channel_id": "1105420521999070702",
+			"token": "c3504777-db15-4332-8286-478a1b5006bc"
+		}
+`
+
+	err := json.Unmarshal([]byte(evtStr), &evt)
+	if err != nil {
+		t.Fatalf("failed to unmarshal evt: %v", err)
+	}
+
+	// logger("evt", debug.PrettyFormatStringJSON(evt))
+
+	subevts, err := evt.SubscriptionEventList()
+	if err != nil {
+		t.Fatalf("failed to get subscription event list: %v", err)
+	}
+
+	logger("evts", debug.PrettyFormatStringJSON(subevts))
+
+	subevt := subevts[0]
+
+	evtType, err := subevt.EventType()
+	if err != nil {
+		t.Fatalf("failed to get updated fields: %v", err)
+	}
+
+	assert.Equal(t, evtType, common.SubscriptionEventTypeUpdate)
+
+	logger("evtType", evtType)
+
+	rawEventName, err := subevt.RawEventName()
+	if err != nil {
+		t.Fatalf("failed to get raw event name: %v", err)
+	}
+
+	assert.Equal(t, rawEventName, "update")
+
+	logger("rawEventName", rawEventName)
+
+	objectName, err := subevt.ObjectName()
+	if err != nil {
+		t.Fatalf("failed to get object name: %v", err)
+	}
+
+	assert.Equal(t, objectName, "Leads")
+
+	logger("objectName", objectName)
+
+	recordId, err := subevt.RecordId()
+	if err != nil {
+		t.Fatalf("failed to get record id: %v", err)
+	}
+
+	logger("recordId", recordId)
+
+	evtTimeStampNano, err := subevt.EventTimeStampNano()
+	if err != nil {
+		t.Fatalf("failed to get event time stamp nano: %v", err)
+	}
+
+	logger("evtTimeStampNano", evtTimeStampNano)
+
+	//nolint:varnamelen
+	subevtUpdateEvent, ok := subevt.(common.SubscriptionUpdateEvent)
+	if !ok {
+		t.Fatalf("failed to cast to subscription update event")
+	}
+
+	updatedFields, err := subevtUpdateEvent.UpdatedFields()
+	if err != nil {
+		t.Fatalf("failed to get updated fields: %v", err)
+	}
+
+	logger("updatedFields", updatedFields)
+}
diff --git a/test/zohocrm/subscribe/subscribe.go b/test/zohocrm/subscribe/subscribe.go
index 5bd143f9..9532bd5b 100644
--- a/test/zohocrm/subscribe/subscribe.go
+++ b/test/zohocrm/subscribe/subscribe.go
@@ -51,9 +51,9 @@ func main() {
 			},
 		},
 		Request: &zohocrm.SubscriptionRequest{
-			UniqueRef:       uniqueRef,
+			UniqueRef:       "amp_" + uniqueRef,
 			WebhookEndPoint: "https://play.svix.com/in/e_BVbta2ttNmjqeA1md230npV13f5/",
-			// Duration:        &dur,
+			Duration:        &dur,
 		},
 	}
 
@@ -92,7 +92,7 @@ func main() {
 			},
 		},
 		Request: &zohocrm.SubscriptionRequest{
-			UniqueRef:       uniqueRef,
+			UniqueRef:       "amp_" + uniqueRef,
 			WebhookEndPoint: "https://play.svix.com/in/e_BVbta2ttNmjqeA1md230npV13f5/",
 			Duration:        &dur,
 		},
@@ -105,8 +105,6 @@ func main() {
 		return
 	}
 
-	fmt.Println("Update subscription results:", prettyPrint(updateResult))
-
 	err = conn.DeleteSubscription(ctx, *updateResult)
 	if err != nil {
 		logging.Logger(ctx).Error("Error deleting subscription", "error", err)
@@ -115,15 +113,6 @@ func main() {
 	}
 
 	fmt.Println("Delete subscription successful")
-
-	records, err := conn.GetRecordsByIds(ctx, "Leads", []string{"6756839000000575405", "6756839000000575402"}, []string{"phone", "company"}, []string{})
-	if err != nil {
-		logging.Logger(ctx).Error("Error getting records", "error", err)
-
-		return
-	}
-
-	fmt.Println("Records:", prettyPrint(records))
 }
 
 func prettyPrint(v any) string {