Go

The operate-as-go library, with clients, actions, pass-through, errors and webhook verification.

github.com/arcarolabs/operate-as-go is the official Go library. It needs Go 1.21 or newer and has no dependencies beyond the standard library. Each service's actions come typed in a module of their own, such as github.com/arcarolabs/operate-as-go/services/sheets: get the ones you use.

Get started

Install

The library, and a module for each service you use:

go get github.com/arcarolabs/operate-as-go github.com/arcarolabs/operate-as-go/services/sheets
import (
	operateas "github.com/arcarolabs/operate-as-go"
	"github.com/arcarolabs/operate-as-go/services/sheets"
)

Create the client with your key

Keep the key from API & webhooks in an environment variable, never in code you share:

export OPERATE_AS_API_KEY="oa_..."
operate := operateas.New(os.Getenv("OPERATE_AS_API_KEY"))

Create one and reuse it: it's safe for concurrent use. An empty key makes New read OPERATE_AS_API_KEY itself. New never fails; with still no key, every call returns an error IsAuthentication reports, without sending a request.

Several keys. Each client uses the key you give it, so a program can use several keys at once: make one per key. OPERATE_AS_API_KEY is only read when you pass an empty key. Each reaches only what its key allows.

reporting := operateas.New(os.Getenv("OPERATE_AS_REPORTING_KEY")) // reads sheets for the weekly report
bookings := operateas.New(os.Getenv("OPERATE_AS_BOOKINGS_KEY"))   // writes to one client's calendar

Call

Every call takes a context.Context, for cancellation and deadlines:

svc := sheets.New(operate, "6f1c2a7e-...") // one of your clients, by id
rows, err := svc.ReadRows(ctx, sheets.ReadRowsInput{SpreadsheetID: "1BxiM...", Range: "A1:D20"})
if err != nil {
	return err
}
fmt.Println(rows.Status, string(rows.Data))

sheets.New sends nothing. The dashboard shows each client's id, and Clients.List lists them with what each has connected:

clients, err := operate.Clients.List(ctx, nil)
if err != nil {
	return err
}
for _, c := range clients {
	fmt.Println(c.ID, c.Name, c.Services) // 6f1c2a7e-... Miller Dental [sheets slack]
}

Options

operate := operateas.New(os.Getenv("OPERATE_AS_API_KEY"),
	operateas.WithBaseURL("https://api.operate.as"), // or set OPERATE_AS_BASE_URL
	operateas.WithTimeout(30*time.Second),           // default 60s
	operateas.WithHTTPClient(&http.Client{}),        // your own transport
)
OptionDefaultWhat it does
WithBaseURLOPERATE_AS_BASE_URL, then DefaultBaseURL (https://api.operate.as)The API's address, with or without /v1
WithTimeoutDefaultTimeout (60 seconds)How long a call may take, whichever http.Client makes it
WithHTTPClientA plain http.ClientFor custom transports, proxies or tests. It's copied, so yours isn't changed

Service modules

Every service with actions has a typed module: github.com/arcarolabs/operate-as-go/services/<package>, where the package is the service's name without dashes, such as aiinbx for AI Inbx. Each has New and For, and each action as a method:

inbox := aiinbx.New(operate, clientID)
_, err := inbox.ReplyToEmail(ctx, aiinbx.ReplyToEmailInput{
	EmailID: emailID,
	Body:    &aiinbx.ReplyToEmailBody{From: "kate@millerdental.com", HTML: "<p>Thanks for booking!</p>"},
})
if err != nil {
	return err
}

rows, err := sheets.New(operate, clientID).ReadRows(ctx, sheets.ReadRowsInput{SpreadsheetID: "1BxiM...", Range: "A1:D20"})

Every input is a struct with documented fields, so the compiler catches a misspelled field or a wrong type. Optional fields are pointers: set one with operateas.Ptr(20). Each module also has Request for any endpoint of the service's own API.

You useA service the client hasn't connected
sheets.New(operate, clientID)Returns an error operateas.IsReconnectRequired reports, from the API
sheets.For(operate, client), with a client from Clients.List or Clients.GetReturns an error operateas.IsNotConnected reports, before anything is sent

Each integration's page in these docs names its module. A module's version moves on when its service's actions change. Without a module, operate.Service(clientID, "sheets").Run(ctx, "read_rows", input) runs any action.

Actions and pass-through

With the service's module:

svc := sheets.New(operate, clientID)
rows, err := svc.ReadRows(ctx, sheets.ReadRowsInput{SpreadsheetID: "1BxiM...", Range: "A1:D20"})
if err != nil {
	return err
}

var sheet struct {
	Values [][]string `json:"values"`
}
if err := rows.Decode(&sheet); err != nil {
	return err
}
fmt.Println(rows.Status, sheet.Values)

operate.Actions.Run(ctx, "sheets.read_rows", clientID, map[string]any{...}) runs an action by name instead. Every action is on Actions.

The result

Run and Request return a *Result:

Field or methodWhat it is
StatusThe service's own HTTP status, such as 200
DataThe service's own response, as json.RawMessage
EncodingHow Data came back: "json", "text" or "base64"
OK()true when Status is 2xx
Decode(v)Unmarshals Data into v

When the service answers with a file, result.Encoding is "base64" and Data holds the file as a base64 JSON string. Decode it into a string, then base64.StdEncoding.DecodeString it. Encoding is "json" for parsed JSON and "text" for plain text. See The result.

if !rows.OK() {
	return fmt.Errorf("sheets said %d: %s", rows.Status, rows.Data)
}

Errors

Check errors with the helpers, which work on wrapped errors too:

HelperStatusWhen it happens
IsInvalidRequest400, 422An input is missing or the wrong type, or the path leaves the service's API
IsAuthentication401The key is wrong or revoked, or no key was given
IsPaymentRequired402The workspace has no plan: choose one in the dashboard
IsPermissionDenied403Outside the key's clients, services or actions, a service the client wasn't asked to connect, outside your plan, or the key is beyond your plan's key allowance, or the client paused the connection or allows only reads
IsNotFound404No such action, service or client
IsReconnectRequired409The client hasn't connected this service, or their access ended
IsNotConnected409The client doesn't have this service connected, so the call wasn't sent. IsReconnectRequired reports it too
IsRateLimited429Your plan allows no more API calls this period. See RetryAfter
IsServiceUnavailable502, 503The service didn't answer, or the provider can't renew access right now
IsConnectionError0Operate As couldn't be reached, or the call timed out

The *operateas.Error itself has:

FieldWhat it is
StatusCodeThe HTTP status, or 0 when no answer arrived
DetailThe plain-words message, such as Miller Dental's Google access ended. Send them their link to reconnect.
RetryAfterAfter a 429, how long to wait, from Retry-After
BodyThe raw error body
ErrThe network error when StatusCode is 0. errors.Unwrap returns it

Other statuses come back as an *Error no helper reports: check StatusCode. Errors building or decoding a request, such as a body that won't marshal or an unknown method, are plain errors, not *Error.

Catch reconnect-required

A client's access ending won't fix itself. Catch it, tell someone, and stop working for that client:

_, err := svc.AddRow(ctx, sheets.AddRowInput{
	SpreadsheetID: "1BxiM...",
	Range:         "A1",
	Values:        []any{"2026-10-16", "Jane Doe"},
})

var apiErr *operateas.Error
switch {
case err == nil:
case operateas.IsReconnectRequired(err):
	errors.As(err, &apiErr)
	notifyTeam(clientID + ": " + apiErr.Detail) // "...Send them their link to reconnect."
	pauseWorkflow(clientID)
case operateas.IsRateLimited(err):
	errors.As(err, &apiErr)
	time.Sleep(apiErr.RetryAfter)
case operateas.IsServiceUnavailable(err):
	retryLater()
default:
	return err
}

See Retries for what's worth retrying.

Verify webhooks

VerifyWebhook(payload, header, secret, tolerance) checks the Operate-As-Signature header and returns the *Event. Read the raw body first:

http.HandleFunc("/hooks/operate-as", func(w http.ResponseWriter, r *http.Request) {
	payload, err := io.ReadAll(r.Body)
	if err != nil {
		http.Error(w, "unreadable body", http.StatusBadRequest)
		return
	}
	event, err := operateas.VerifyWebhook(payload, r.Header.Get(operateas.SignatureHeader),
		os.Getenv("OPERATE_AS_WEBHOOK_SECRET"), 0)
	if err != nil {
		http.Error(w, "bad signature", http.StatusBadRequest)
		return
	}

	if event.Type == operateas.EventConnectionExpired {
		emailClient(event.Data.ClientName, event.Data.ReconnectURL)
	}
	w.WriteHeader(http.StatusNoContent)
})
PartDetail
toleranceHow old a delivery may be. 0 uses DefaultTolerance (300 seconds); a negative value skips the check
FailuresEvery one wraps ErrInvalidSignature, so errors.Is(err, operateas.ErrInvalidSignature) tells a bad delivery apart. Signatures are compared in constant time
EventID, Type, CreatedAt (a time.Time) and Data
EventDataClientID, ClientName, Provider, Services, ReconnectURL, or for api_key events KeyID, KeyName, Address
Event constantsEventConnectionConnected, EventConnectionExpired, EventConnectionRemoved, EventConnectionAccountChanged, EventConnectionLimited, EventClientPasskeySaved, EventClientCompleted, EventKeyNewAddress, EventKeyRefusedAddress, EventWebhookTest
SignWebhook(payload, secret, time.Now())Builds a valid header, for testing your handler

See Webhooks for the events.

Types

TypeWhat it is
ClientThe API client from New, with Clients and Actions
ConnectedClientOne of your clients: ID, Name, Services
ServiceOne service for one client, from Client.Service or Client.ServiceFor, with Run and Request
Action, ActionInput, ActionPageAn action, its inputs, and a page of actions. ActionInput.Type is "string", "number", "integer", "boolean", "array" or "object"
ResultStatus, Data and Encoding, with OK() and Decode
RequestOptionsQuery and Body for Request
ErrorEvery failed call to the API
Event, EventDataA webhook delivery

Result.Data is raw JSON because each service answers in its own shape. Decode it into a struct that matches the service's docs.

On this page