The official Go SDK for the Warmbly cold-outreach & mailbox-warmup platform.
warmbly-go is a fully typed client for the Warmbly REST API and its realtime event gateway. It covers the whole customer-facing v1 surface — mailboxes and warmup, campaigns and sequences, contacts and CRM, the unified inbox, integrations and automations, AI generation and the Advisor, analytics, webhooks, keys and billing — plus a persistent gateway connection for live events. It has zero external dependencies: the entire module is built on the Go standard library, including a dependency-free RFC 6455 WebSocket implementation, so adding it pulls in nothing but Warmbly itself.
- The whole API, typed. Every service hangs off one
Client; see Services for the map. - Flexible authentication. API keys via
warmbly.WithAPIKey, OAuth 2.1 access tokens viawarmbly.WithAccessToken/warmbly.WithTokenSource, and session tokens fromclient.Auth.Loginfor the routes an API key deliberately cannot reach. Full OAuth client flows: authorization-code with PKCE and client-credentials. - Safe retries. Exponential backoff with jitter honoring
Retry-After, plus per-requestIdempotency-Keysupport so retrying a send never sends twice. - Typed errors. A decoded
*warmbly.Errorcarrying the request ID and message, matchable witherrors.Isagainst sentinels such aswarmbly.ErrNotFoundandwarmbly.ErrRateLimited. - Cursor pagination. A generic
Page[T]with a Go 1.23 auto-paging iterator. - Webhook verification. Signature and replay checking via
client.Webhooks.ConstructEvent. - Realtime gateway. A persistent connection with intent filtering, typed handlers, heartbeats, reconnection and sequence-based replay of missed events.
- Forward compatible.
client.Doreaches an endpoint this release does not model yet, andWithQueryParamadds a filter that landed after it shipped. - Zero dependencies. Standard library only. No transitive supply chain to audit.
go get github.com/warmbly/warmbly-go
Requires Go 1.23+ (the auto-paging iterator uses range-over-func).
package main
import (
"context"
"fmt"
"log"
"github.com/warmbly/warmbly-go"
)
func main() {
client, err := warmbly.New(warmbly.WithAPIKey("wmbly_..."))
if err != nil {
log.Fatal(err)
}
ctx := context.Background()
page, err := client.Campaigns.List(ctx, nil)
if err != nil {
log.Fatal(err)
}
for campaign, err := range page.All(ctx) {
if err != nil {
log.Fatal(err)
}
fmt.Println(campaign.Name)
}
}The simplest way to authenticate is with a Warmbly API key (they start with the wmbly_ prefix):
client, err := warmbly.New(warmbly.WithAPIKey("wmbly_..."))
if err != nil {
log.Fatal(err)
}For applications acting on behalf of users, use the OAuth 2.1 authorization-code flow with PKCE:
cfg := &warmbly.OAuth2Config{
ClientID: "...",
ClientSecret: "...",
RedirectURL: "https://app.example.com/callback",
Scopes: []string{"campaigns:read", "contacts:read"},
}
verifier := warmbly.GenerateVerifier()
authURL := cfg.AuthCodeURL("state-xyz", warmbly.S256ChallengeOption(verifier))
// redirect the user to authURL; on the callback:
tok, err := cfg.Exchange(ctx, code, warmbly.VerifierOption(verifier))
client, err := cfg.NewClient(ctx, tok)For machine-to-machine access, use the client-credentials flow via warmbly.ClientCredentialsConfig. You can register, list, and manage your OAuth applications programmatically through client.OAuthApps.
If you already hold an access token, authenticate directly with warmbly.WithAccessToken:
client, err := warmbly.New(warmbly.WithAccessToken("..."))Transparent refresh. Pass a token source with
warmbly.WithTokenSourceto have the client fetch and refresh tokens automatically, so requests never fail on an expired access token. The configs returned by the OAuth flows produce clients backed by a refreshing token source out of the box.
Some of the API is deliberately unreachable with a long-lived key: workspace governance, billing, and the AI assistant all act as a named person rather than an integration. client.Auth signs a user in and yields a session token for those.
Sign-in is two steps. The first emails a code; the second exchanges it for tokens. An account with two-factor enabled takes one more, through Auth.VerifyTwoFA.
session, _, err := client.Auth.Login(ctx, &warmbly.LoginParams{Email: email, Password: password})
tokens, _, err := client.Auth.LoginConfirm(ctx, &warmbly.ConfirmParams{Session: session, Code: emailedCode})
authed, err := warmbly.New(warmbly.WithAccessToken(tokens.AccessToken))
org, _, err := authed.Organization.Current(ctx)An API key used on one of these routes gets a clean warmbly.ErrUnauthorized rather than a confusing failure.
Every resource is exposed as a service on the Client. Listing returns a page that you can iterate with the auto-paging iterator; individual records are fetched by ID, and most resources support creation:
ctx := context.Background()
// List with automatic pagination.
page, err := client.Campaigns.List(ctx, nil)
if err != nil {
log.Fatal(err)
}
for campaign, err := range page.All(ctx) {
if err != nil {
log.Fatal(err)
}
fmt.Println(campaign.Name)
}
// Fetch a single record by ID (single-record calls also return the *Response).
campaign, _, err := client.Campaigns.Get(ctx, "camp_123")
if err != nil {
log.Fatal(err)
}
// Create a new record.
created, _, err := client.Campaigns.Create(ctx, &warmbly.CampaignCreateParams{
Name: "Q3 outbound",
})
if err != nil {
log.Fatal(err)
}
fmt.Println(created.ID)| Service | What it covers |
|---|---|
client.Emails |
Connected mailboxes, warmup lifecycle, domain authentication, address verification, one-off sends |
client.Campaigns |
Campaigns, sequence steps, A/B variants, attachments, senders, preflight, template preview |
client.Contacts |
Contacts and the 360 view, faceted search, address verification, CRM notes, import and export, AI research |
client.Segments |
Saved contact audiences, evaluated live, with per-contact overrides |
client.Suppressions |
The workspace do-not-contact list |
client.Forms |
Hosted lead-capture forms, their submissions and their custom domain |
client.Unibox |
Unified inbox: reading, replying, composing, labels, snoozes, scheduled sends, AI drafts |
client.Templates |
Reply templates, spam scoring, rendering, ordering |
client.Analytics |
Dashboard, per-campaign engagement, warmup progress, deliverability health, plan usage |
client.Advisor |
Continuous checks on sending posture, with one-click and agent fixes |
client.CRM |
Pipelines, deals, task types and the task board |
client.Teams |
Named groups of members for CRM assignment |
client.Meetings |
Calls booked through a connected scheduler |
client.Integrations |
Third-party connections, event subscriptions, field mappings, contact pushes |
client.Automations |
Event-triggered flows across those connections |
client.LeadSync |
Google Sheets to contacts sync |
client.Generation |
AI writing and rewriting |
client.Skills |
Workspace AI playbooks that steer it |
client.AgentTools |
The AI tool registry over plain HTTP, for function-calling agents |
client.Webhooks |
Endpoints, the event catalog, and the delivery log |
client.APIKeys |
Keys, scopes and usage analytics |
client.OAuthApps |
OAuth 2.1 application registration and the consent flow |
client.Outreach |
Organization-wide sending policy |
client.Deliverability |
Bounce and complaint ingestion from an upstream pipeline |
client.WarmupRouting |
Warmup partner-selection rules |
client.Tasks |
Send-task dead-letter queue |
client.AuditLogs |
The organization audit trail |
client.Folders / Tags / Categories |
The label sets that organize campaigns, mailboxes and contacts |
client.Meta |
Caller identity, plan catalog, timezones |
client.Auth |
Sign-in, sessions, profile, two-factor, passkeys, notifications |
client.Organization |
Workspace settings, members, roles, invitations, danger zone |
client.Billing |
Subscription, plan changes, AI credits, referrals |
client.WebsiteTracking |
The website tracking snippet's consent, precision, hosts and retention |
client.PoolLink |
Self-hosted instances linked to this workspace's warmup pool |
client.CloudLink |
A self-hosted instance's own side of that link |
Auth, Organization, Billing, WebsiteTracking, PoolLink and CloudLink are session-only; see Session tokens.
The API moves faster than this SDK's release cadence. client.Do issues a request against any path, with the same authentication, retries and typed errors as a generated method:
var out map[string]any
_, err := client.Do(ctx, http.MethodGet, "some/new/endpoint", nil, &out)warmbly.WithQueryParam does the same for a filter that a typed parameter struct does not carry yet.
Anything that sends mail or spends money accepts an Idempotency-Key. Retrying with the same key replays the original response instead of acting twice, which turns an ambiguous timeout into a safe retry:
result, resp, err := client.Emails.Send(ctx, mailboxID, params,
warmbly.WithIdempotencyKey("order-4171-welcome"))
if resp.IdempotentReplayed {
// The original send already went out; this was a replay.
}List endpoints use cursor-based pagination. Each call returns a page object, and rather than threading cursors through your own loop you can range over page.All(ctx) — a Go 1.23 range-over-func iterator that transparently fetches subsequent pages as you consume items, stopping on the first error:
page, err := client.Emails.List(ctx, nil)
if err != nil {
log.Fatal(err)
}
for email, err := range page.All(ctx) {
if err != nil {
log.Fatal(err)
}
fmt.Println(email.Email)
}The iterator yields both a value and an error on each step, so per-page fetch failures surface inline; break out of the loop at any time to stop paging.
API failures are decoded into a typed *warmbly.Error. Match well-known conditions with errors.Is, or unwrap the full error with errors.As to read details such as the request ID:
if errors.Is(err, warmbly.ErrNotFound) {
// ...
}
var apiErr *warmbly.Error
if errors.As(err, &apiErr) {
log.Printf("request %s failed: %s", apiErr.RequestID, apiErr.Message)
}Key sentinels include warmbly.ErrNotFound, warmbly.ErrUnauthorized, and warmbly.ErrRateLimited.
Sentinels match on the status code, which is often not specific enough to act on: several distinct refusals share a 403. Every error also carries a stable machine-readable code, so branch on that when the remedy differs:
var apiErr *warmbly.Error
if errors.As(err, &apiErr) && apiErr.HasCode(warmbly.ErrCodeMailboxAllowanceReached) {
// The workspace holds its whole mailbox allowance. Request an increase
// rather than retrying, which will keep failing.
}The codes are declared as warmbly.ErrCode* constants.
The client automatically retries transient failures using exponential backoff with jitter, and honours the Retry-After header when the server sends one. Rate-limit headers from each response are parsed and exposed on the returned *Response (resp.RateLimit) so you can observe your remaining quota. Tune retry behaviour with the warmbly.WithMaxRetries option:
client, err := warmbly.New(
warmbly.WithAPIKey("wmbly_..."),
warmbly.WithMaxRetries(5),
)The gateway subpackage holds a websocket open to a workspace and delivers typed events as they happen. Declare the intents you want, register handlers with gateway.On, and the client handles heartbeats, reconnection and replay of missed events.
import "github.com/warmbly/warmbly-go/gateway"
g := gateway.New(apiKey, orgID,
gateway.WithIntents(gateway.IntentCampaign, gateway.IntentEmail))
gateway.On(g, gateway.EventEmailOpened, func(ctx context.Context, e *gateway.EngagementEvent) {
log.Printf("contact %s opened a message", e.ContactID)
})
if err := g.Open(ctx); err != nil {
log.Fatal(err)
}
defer g.Close()
<-ctx.Done()Every event carries a monotonic per-workspace sequence number. The client replays the gap after a reconnect, so a brief drop loses nothing; replay is at-least-once, so deduplicate on Event.Seq if your handler is not idempotent. A disconnect that outlasts the server's buffer surfaces as EventResumeFailed, your cue to resync from the REST API.
A refused channel join surfaces as a *gateway.JoinError carrying the server's code and reason slug. Its Permanent method separates the refusals worth retrying from the ones that will never succeed: a topic you may not see, or an id that does not resolve, is final. A join refused for rate limiting is retried automatically on the same socket once the server's retry_after_ms elapses, so no reconnect is spent on it.
Intents only ever narrow the stream — a credential without unibox access receives no inbox events however it asks. Matching is a substring test against the event type, so an intent must be specific enough not to catch its neighbours. The underlying transport is the dependency-free RFC 6455 implementation in internal/wsconn, so the gateway adds no third-party packages either.
Verify every inbound delivery before trusting it. client.Webhooks.ConstructEvent checks the HMAC-SHA256 signature from the X-Warmbly-Signature header and rejects a stale one, which defeats replay:
event, err := client.Webhooks.ConstructEvent(body, r.Header.Get(warmbly.WebhookSignatureHeader), endpointSecret)
switch {
case errors.Is(err, warmbly.ErrWebhookSignatureExpired):
http.Error(w, "stale signature", http.StatusUnauthorized)
return
case err != nil:
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}A new endpoint receives nothing until it proves it owns its URL. Call client.Webhooks.Verify; Warmbly then sends a signed webhook.test delivery carrying a challenge token, which you echo back in the X-Warmbly-Webhook-Challenge response header. Take the token from the verified payload, not from the copy in the request header — that copy is attacker-controllable, the signed body is not. See examples/webhooks for the full handler.
Deliveries retry, so deduplicate on event.ID.
For lower-level use, warmbly.VerifyWebhookSignature and warmbly.ConstructWebhookEvent expose the same checks with an explicit tolerance.
The examples/ directory has a runnable program for each part of the SDK — API keys, both OAuth flows, campaigns, contacts, emails/warmup, templates, analytics, webhooks, the real-time gateway, and error handling. See examples/README.md for the full index.
warmbly-go follows semantic versioning. While the module is pre-1.0, the public API may change between minor releases; review the release notes before upgrading.
Contributions are welcome! Please read CONTRIBUTING.md and our CODE_OF_CONDUCT.md before opening an issue or pull request.
Released under the MIT License. See LICENSE for details.