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/sheetsimport (
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 calendarCall
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
)| Option | Default | What it does |
|---|---|---|
WithBaseURL | OPERATE_AS_BASE_URL, then DefaultBaseURL (https://api.operate.as) | The API's address, with or without /v1 |
WithTimeout | DefaultTimeout (60 seconds) | How long a call may take, whichever http.Client makes it |
WithHTTPClient | A plain http.Client | For 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 use | A 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.Get | Returns 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 method | What it is |
|---|---|
Status | The service's own HTTP status, such as 200 |
Data | The service's own response, as json.RawMessage |
Encoding | How 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:
| Helper | Status | When it happens |
|---|---|---|
IsInvalidRequest | 400, 422 | An input is missing or the wrong type, or the path leaves the service's API |
IsAuthentication | 401 | The key is wrong or revoked, or no key was given |
IsPaymentRequired | 402 | The workspace has no plan: choose one in the dashboard |
IsPermissionDenied | 403 | Outside 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 |
IsNotFound | 404 | No such action, service or client |
IsReconnectRequired | 409 | The client hasn't connected this service, or their access ended |
IsNotConnected | 409 | The client doesn't have this service connected, so the call wasn't sent. IsReconnectRequired reports it too |
IsRateLimited | 429 | Your plan allows no more API calls this period. See RetryAfter |
IsServiceUnavailable | 502, 503 | The service didn't answer, or the provider can't renew access right now |
IsConnectionError | 0 | Operate As couldn't be reached, or the call timed out |
The *operateas.Error itself has:
| Field | What it is |
|---|---|
StatusCode | The HTTP status, or 0 when no answer arrived |
Detail | The plain-words message, such as Miller Dental's Google access ended. Send them their link to reconnect. |
RetryAfter | After a 429, how long to wait, from Retry-After |
Body | The raw error body |
Err | The 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)
})| Part | Detail |
|---|---|
tolerance | How old a delivery may be. 0 uses DefaultTolerance (300 seconds); a negative value skips the check |
| Failures | Every one wraps ErrInvalidSignature, so errors.Is(err, operateas.ErrInvalidSignature) tells a bad delivery apart. Signatures are compared in constant time |
Event | ID, Type, CreatedAt (a time.Time) and Data |
EventData | ClientID, ClientName, Provider, Services, ReconnectURL, or for api_key events KeyID, KeyName, Address |
| Event constants | EventConnectionConnected, 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
| Type | What it is |
|---|---|
Client | The API client from New, with Clients and Actions |
ConnectedClient | One of your clients: ID, Name, Services |
Service | One service for one client, from Client.Service or Client.ServiceFor, with Run and Request |
Action, ActionInput, ActionPage | An action, its inputs, and a page of actions. ActionInput.Type is "string", "number", "integer", "boolean", "array" or "object" |
Result | Status, Data and Encoding, with OK() and Decode |
RequestOptions | Query and Body for Request |
Error | Every failed call to the API |
Event, EventData | A 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.