Skip to content

Repository files navigation

HEY SDK

Go, Rust, TypeScript, Kotlin and Swift SDKs for the HEY API, generated from a Smithy model of the API in spec/, so what an SDK offers is what HEY actually serves.

The repository ships a Go module, github.com/basecamp/hey-sdk/go, which is the library behind hey-cli, a Rust crate, hey-sdk in rust/, the proposed TypeScript package @37signals/hey, a Kotlin library, com.basecamp:hey-sdk in kotlin/, and a Swift package, Hey, in swift/. The Go walkthrough is first; the Rust one, the Kotlin one and the Swift one follow it, and each library's own README (Rust, Kotlin, Swift) goes further. See the TypeScript guide for installation, Node support, all modeled operations, pagination and examples. TypeScript registry provisioning is pending; maintainer activation is required before npm publication.

Ruby remains unimplemented; its inherited Makefile targets are not gates. The Go usage guide follows; a per-language entry point is also available.

Install

go get github.com/basecamp/hey-sdk/go@latest

Requires Go 1.26 or newer. The Rust crate's install line is in its section.

Authenticate

Static token (scripts, agents, anything that already holds a token):

import hey "github.com/basecamp/hey-sdk/go/pkg/hey"

cfg := hey.DefaultConfig() // https://app.hey.com
client := hey.NewClient(cfg, &hey.StaticTokenProvider{Token: os.Getenv("HEY_TOKEN")})

OAuth 2.0 with PKCE (user-facing apps): hey.NewAuthManager handles the token lifecycle and refresh, and the oauth subpackage provides discovery, PKCE and the code exchange. Anything else can plug in with hey.WithAuthStrategy, which sets headers on each request — hey-cli uses this to bridge its own credential store.

Use it

ctx := context.Background()

boxes, _ := client.Boxes().List(ctx)          // Imbox, The Feed, Paper Trail, ...
imbox, _ := client.Boxes().GetImbox(ctx, nil)   // postings in the Imbox

// Sending: recipients are required — HEY saves an unaddressed reply as a draft.
_ = client.Messages().Create(ctx, "Subject", "Body", []string{"someone@example.com"}, nil, nil)

// Select one of the identity's configured senders when the message needs a specific From address.
_ = client.Messages().Send(ctx, hey.MessageContent{
    Subject:        "Support follow-up",
    Content:        "Here are the details we discussed.",
    To:             []string{"jane@example.com"},
    ActingSenderID: supportSenderID,
})

// Replying: start from the NewReply prefill — it carries the reply's subject, its
// acting sender (0 = the account default) and the recipients HEY resolved.
prefill, err := client.Entries().NewReply(ctx, entryID)
if err != nil {
	return err // nothing to reply with
}
var to []string
for _, contact := range prefill.Addressed.Directly {
	to = append(to, contact.EmailAddress)
}
_ = client.Entries().CreateReply(ctx, entryID, prefill.Sender.Id, prefill.Subject, "Reply body", to, nil, nil)

// Postings are bulk operations, as they are in HEY.
_ = client.Postings().MoveToSetAside(ctx, postingID)
_ = client.Postings().MarkSeen(ctx, []int64{a, b})

// Calendar
rec, _ := client.TimeTracks().Start(ctx)
_ = client.TimeTracks().Stop(ctx, rec.Id)

Services on the client: Identity, Boxes, Postings, Topics, Messages, Entries, Contacts, Calendars, CalendarTodos, CalendarEvents, Habits, TimeTracks, Journal, Search, Folders, Collections, Stickies, Clips, Snippets, Workflows, Publications, Designations, Extenzions, World.

Linked accounts and separate identities

A root client represents one authenticated HEY identity and presents mail from All Accounts. Derive an immutable client to present mail and choose acting users and senders for one linked account:

work, err := client.ForAccount(ctx, workAccountID)
if err != nil {
    return err
}
postings, _ := work.Boxes().GetImbox(ctx, nil)
_ = work.Messages().Create(ctx, "Subject", "Body", []string{"someone@example.com"}, nil, nil)

ForAccount verifies that the account is accessible to the authenticated identity when the scoped client is derived, then adds HEY's filtered_account_id to same-origin API requests, including pagination and retries. Long-lived applications can derive a fresh scoped client after observing identity or account-membership changes. It never adds the filter to signed external upload or download URLs. Account-scoped message sends resolve a sender from that account, and account-scoped contact creation resolves the identity's user in that account. Both operations return an error when the account has no matching sender or user rather than falling back to another account.

Account scope follows HEY's mail-filter semantics; it is not an authorization boundary. Identity-owned services such as Calendar and Journal remain identity-wide. Use a client derived for a thread's account when replying or forwarding that thread.

Separate, unlinked identities use separate root clients with separate token providers or auth strategies. Each root can independently derive its own linked-account clients:

personal := hey.NewClient(cfg, personalTokenProvider)
workIdentity := hey.NewClient(cfg, workTokenProvider)

personalMail, _ := personal.ForAccount(ctx, personalAccountID)
workMail, _ := workIdentity.ForAccount(ctx, workAccountID)

Every call reports itself to the client's Hooks (hey.WithHooks) as a named operation — Postings.MovePostings, TimeTracks.StopTimeTrack — and a GatingHooks implementation can refuse an operation before it runs. Circuit breaking, bulkheads and rate limits are configured with WithResilience, WithCircuitBreaker, WithBulkhead and WithRateLimit; HTTP caching with WithCache. Response caching is active for requests with an Authorization header, which gives each authenticated identity a stable cache partition.

Every modelled operation carries its retry policy from the API contract: how many sends it gets in all, which statuses earn another, and the wait before the first resend. The client honours that policy on the first request and on every page read after it, and its own settings only ever make it gentler: WithMaxRetries caps the sends (an operation modelled with two sends gets two whatever the cap, and a cap of one resend holds an operation modelled with three to two), WithBaseDelay is the least the client waits before the first resend, and a status the policy does not name is the operation's answer. An operation that is not idempotent is sent once, and so is one the contract gives no policy. Whatever the count, a 401 that a credential refresh answered earns one more send. The refresh is one per set of credentials, not one per request: every request signed with the same stale token shares the one refresh their 401s earn, and a refresh that could not renew them is shared the same way, so a rotating refresh token is spent once and an outage at the issuer costs one call; a request signed after that failure asks again. A GET on a path the caller wrote (Get, GetAll) has no policy to bring and runs on the client's settings alone, resent on 429, 502, 503 and 504.

JSON and HTML answers are capped in the transport at WithMaxResponseBodyBytes (16 MiB of decompressed body by default; the cap can be raised but not removed), success and error responses alike. A body past it fails with an error that errors.Is(err, hey.ErrResponseTooLarge), and is not retried; a refused error response still carries its status in the *hey.Error. Buffered blobs and CSV exports (GetBlob, GetCSV) are bounded by the 50 MiB hey.MaxResponseBodyBytes constant instead; only DownloadBlob streams without a bound.

Errors

Calls return *hey.Error with a stable Code (hey.CodeNotFound, hey.CodeAuth, hey.CodeForbidden, hey.CodeRateLimit, hey.CodeConflict, hey.CodeUsage, ...), the HTTP status, and — for auth and scope problems — a hint. hey.AsError(err) unwraps it.

Pagination

Paged reads follow HEY's Link headers automatically, up to WithMaxPages.

Rust

The crate is hey-sdk, at rust/hey-sdk, with the same generated surface as the Go module and the same hand-written conveniences on top. It is an async client on tokio, sends over rustls by default, and lets an application bring its own HTTP stack instead.

Install

[dependencies]
hey-sdk = "0.31"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }

The crate is hey-sdk on crates.io, documented on docs.rs. To track the repository instead, depend on it at a release tag: hey-sdk = { git = "https://github.com/basecamp/hey-sdk", tag = "v0.31.1" }. Requires Rust 1.88 or newer (rust-version in rust/Cargo.toml, built on exactly that in CI); the crate's Versioning section says when that floor moves and what a version bump means.

Authenticate

A fixed token, for scripts and anything that already holds one:

use hey_sdk::{Client, Config, StaticTokenProvider};

let client = Client::new(Config::default(), StaticTokenProvider::new(std::env::var("HEY_TOKEN")?))?;

OAuth 2.0 with PKCE, for user-facing apps: hey_sdk::oauth speaks the protocol — the authorization URL, the code exchange and refresh, with the install_id HEY wants on each. The application keeps the tokens and hands the client a TokenProvider over them; the client asks it to refresh once when HEY answers 401. Anything that wants the request headers outright implements AuthStrategy instead. examples/oauth_pkce.rs is the whole flow.

Use it

use hey_sdk::services::MessageContent;

let boxes = client.boxes().list().await?;                       // Imbox, The Feed, Paper Trail, ...
let imbox = client.boxes().get_imbox(&Default::default()).await?;

// Sending: recipients are required — HEY saves an unaddressed message as a draft.
client.messages().send(&MessageContent {
    subject: "Subject".into(),
    content: "<div>Body</div>".into(),
    to: vec!["someone@example.com".into()],
    ..Default::default()
}).await?;

// Replying: start from the prefill — the subject, the acting sender, the recipients HEY resolved.
let prefill = client.entries().new_reply(entry_id).await?;

// Postings are bulk operations, as they are in HEY.
client.postings().move_to_set_aside(&[posting_id]).await?;
client.postings().mark_postings_seen(&[a, b]).await?;

// Calendar
let track = client.time_tracks().start_tracking().await?;
client.time_tracks().stop(track.id).await?;

Services on the client, one handle per resource: attachments, boxes, bulk_replies, calendar_events, calendar_periods, calendar_todos, calendars, clearances, clips, collections, contacts, designations, entries, extenzions, folders, habits, identity, journal, messages, postings, publications, search, snippets, stickies, time_tracks, topics, workflows, world. Every method the model describes is generated, named for the operation with the service's noun dropped (ListBoxes is boxes().list()); the hand-written ones in rust/hey-sdk/src/services take the arguments a caller has and cover the parts of HEY the model cannot describe. Every route is data in hey_sdk::routes, and hey_sdk::url::router() names the operation a pasted HEY URL refers to.

Linked accounts

A root client presents mail from All Accounts. Derive one for a linked account to present that account's mail and act as its user and default sender; it adds HEY's filtered_account_id to every same-origin request, including pagination and retries, and never to signed external URLs:

let work = client.for_account(work_account_id).await?;
let postings = work.boxes().get_imbox(&Default::default()).await?;

Errors

Every call answers Result<_, hey_sdk::Error>: a stable ErrorCode (NotFound, Auth, Forbidden, RateLimit, Validation, Api, Usage, ...), the HTTP status, whether it is worth retrying, HEY's X-Request-Id, a hint when there was one, and the body HEY answered the failure with, for the endpoints that describe a refusal there.

Pagination

Paged reads answer a Page<T>, which derefs to the response and carries the next cursor and X-Total-Count; next_page reads on, each_page walks to the client's max_pages, and a Link that points off the HEY origin is refused. examples/pagination.rs shows both walks.

Beyond the Go module

Everything the crate sends goes through one HttpClient trait, so a binary that already has an HTTP stack — a mobile shell on the platform's own — does not get a second one (examples/custom_http_client.rs runs on a canned transport, without the network and without the reqwest feature). Hooks report every operation, request and resend (examples/hooks.rs), and a gate can refuse an operation before it is sent. Retries, a circuit breaker, a bulkhead, a rate limit and an ETag response cache are on the builder. Secrets are SensitiveStrings that print as [REDACTED], response bodies are capped, and HTTPS is enforced off localhost.

The examples under rust/hey-sdk/examples compile in CI; HEY_TOKEN=... cargo run --example first_call from rust/ is the quickest first call.

Kotlin

The library is com.basecamp:hey-sdk, at kotlin/sdk, with the same generated surface as the Go module and the Rust crate and the same hand-written conveniences on top. It is a coroutine client on Ktor and kotlinx.serialization for the JVM.

Install

repositories {
    mavenCentral()
    maven {
        url = uri("https://maven.pkg.github.com/basecamp/hey-sdk")
        credentials {
            username = project.findProperty("gpr.user") as String? ?: System.getenv("GITHUB_USER")
            password = project.findProperty("gpr.key") as String? ?: System.getenv("GITHUB_ACCESS_TOKEN")
        }
    }
}

dependencies {
    implementation("com.basecamp:hey-sdk:0.31.1")
}

The library is on GitHub Packages, which wants a token with the read:packages scope for every download; kotlin/README.md has the three steps. Publishing there is switched off until it is sorted out, so until the first release lands, cd kotlin && ./gradlew :hey-sdk:publishToMavenLocal and depend on it from mavenLocal(). Requires JDK 17 and Kotlin 2.3 or newer.

Authenticate

import com.basecamp.hey.HeyClient

val client = HeyClient { accessToken(System.getenv("HEY_TOKEN")) }

An application that keeps OAuth tokens hands the client a TokenProvider over them; the client asks it to refresh() once when HEY answers 401 and sends the request again. Anything that wants the request headers outright implements AuthStrategy instead.

Use it

import com.basecamp.hey.generated.*          // the service accessors: client.boxes, client.messages, ...
import com.basecamp.hey.services.*           // MessageContent, ReplyContent, BoxKind, ...

val boxes = client.boxes.list()                       // a Page: .value is what HEY answered
val imbox = client.boxes.getImbox()

// Sending: recipients are required — HEY saves an unaddressed message as a draft.
client.messages.send(MessageContent(
    subject = "Subject",
    content = "<div>Body</div>",
    to = listOf("someone@example.com"),
))

// Replying: start from the prefill — the subject, the acting sender, the recipients HEY resolved.
val prefill = client.entries.newReply(entryId)

// Postings are bulk operations, as they are in HEY.
client.postings.moveToSetAside(listOf(postingId))
client.postings.markPostingsSeen(listOf(a, b))

// Calendar
val track = client.timeTracks.startTracking()
client.timeTracks.stop(track.id)

Services are extension properties of the client in com.basecamp.hey.generated, one per resource — client.boxes, client.messages, client.timeTracks — and every method the model describes is generated, named for the operation with the service's noun dropped (ListBoxes is client.boxes.list()). The hand-written conveniences are subclasses in com.basecamp.hey.services, which the accessors hand out. Every route is data in com.basecamp.hey.generated.Routes.

Linked accounts

client.forAccount(42) derives a client for one linked account, checks the account against the identity, and adds filtered_account_id to every request on the HEY origin, the next pages of a walk included. defaultSenderId() and accountUserId() answer what that account acts as.

Beyond the Go module

Every failure is a HeyException, a sealed class over the shared error vocabulary, with the status, the request id, HEY's own message and the failure body on it. Each route carries the retry policy the model gives it, and the client's settings only lower it; a 401 is answered by one refresh and one resend. Hooks report every operation, request and resend, an opt-in ResponseCache revalidates JSON reads by ETag, secrets are SensitiveStrings that print as [REDACTED], response bodies are capped, and HTTPS is enforced off localhost.

Swift

The package is Hey, at swift/, with the same generated surface as the other libraries and the same hand-written conveniences on top. It is an async/await client on URLSession with strict Swift 6 concurrency, for macOS, iOS and Linux.

Install

dependencies: [
    .package(url: "https://github.com/basecamp/hey-sdk", from: "0.31.1"),
]

Add the Hey product to your target. There is no registry: Swift Package Manager resolves the package from this repository's version tag and reads the Package.swift at its root. In Xcode, File > Add Package Dependencies takes the same URL. Requires Swift 6.0 or newer, on macOS 13, iOS 16, or Linux.

Authenticate

import Hey

let client = try HeyClient(accessToken: ProcessInfo.processInfo.environment["HEY_TOKEN"] ?? "")

An application that keeps OAuth tokens hands the client a TokenProvider over them; the client asks it to refresh() once when HEY answers 401 and sends the request again. Anything that wants the request headers outright conforms to AuthStrategy instead.

Use it

let boxes = try await client.boxes.list()             // a Page: .value is what HEY answered
let imbox = try await client.boxes.getImbox()

// Sending: recipients are required — HEY saves an unaddressed message as a draft.
try await client.messages.send(MessageContent(
    subject: "Subject",
    content: "<div>Body</div>",
    to: ["someone@example.com"]
))

// Replying: start from the prefill — the subject, the acting sender, the recipients HEY resolved.
let prefill = try await client.entries.newReply(entryId: entryId)

// Postings are bulk operations, as they are in HEY.
try await client.postings.moveToSetAside(postingIds: [postingId])
try await client.postings.markPostingsSeen(postingIds: [a, b])

// Calendar
let track = try await client.timeTracks.startTracking()
try await client.timeTracks.stop(timeTrackId: track.id)

Services are properties of the client, one per resource — client.boxes, client.messages, client.timeTracks — and every method the model describes is generated, named for the operation with the service's noun dropped (ListBoxes is client.boxes.list()). The hand-written conveniences are extensions of the same services. Every route is data in Routes.

Linked accounts

try await client.forAccount(42) derives a client for one linked account, checks the account against the identity, and adds filtered_account_id to every request on the HEY origin, the next pages of a walk included. defaultSenderId() and accountUserId() answer what that account acts as.

Beyond the Go module

Every failure is a HeyError, an enum over the shared error vocabulary, with the status, the request id, HEY's own message and the failure body on it. Each route carries the retry policy the model gives it, and the client's settings only lower it; a 401 is answered by one refresh and one resend. Hooks report every operation, request and resend, an opt-in ResponseCache revalidates JSON reads by ETag, secrets are SensitiveStrings that print as [REDACTED], response bodies are capped, HTTPS is enforced off localhost, and cancelling a task cancels the call it is making.

How the SDK is built

spec/hey.smithy ──► openapi.json ──► oapi-codegen ──► go/pkg/generated/client.gen.go
                        │                                       │
                        │                     hand-written services in go/pkg/hey call into it
                        │
                        ├─────────► rust/generator ──► rust/hey-sdk/src/generated/
                        │                                       │
                        │                 hand-written conveniences in rust/hey-sdk/src/services
                        │
                        ├─────────► kotlin/generator ──► kotlin/sdk/src/commonMain/kotlin/com/basecamp/hey/generated/
                        │                                       │
                        │   hand-written subclasses in kotlin/sdk/src/commonMain/kotlin/com/basecamp/hey/services
                        │
                        └─────────► swift/Sources/HeyGenerator ──► swift/Sources/Hey/Generated/
                                                                │
                                  hand-written extensions in swift/Sources/Hey/Services

TypeScript generates types, operation methods, route/behavior metadata and guards from openapi.json + behavior-model.json into typescript/src/generated/.

The Smithy model is the source of truth for routes and payloads. openapi.json, behavior-model.json, client.gen.go, go/pkg/hey/url-routes.json, everything under rust/hey-sdk/src/generated/, kotlin/sdk/src/commonMain/kotlin/com/basecamp/hey/generated/, swift/Sources/Hey/Generated/ and the files under spec/ that describe coverage are all regenerated from it — editing them by hand is lost on the next build. The services in go/pkg/hey are written by hand and add the things a generated client cannot know: which recipients a reply needs, that HEY answers a shared topic's trash request with a confirmation page, that starting a time track takes no body. The Rust crate generates its service methods too, and adds those same conveniences by hand in rust/hey-sdk/src/services.

make check verifies the model against a snapshot of HEY's own routes (spec/route-snapshot.json, pinned in spec/api-provenance.json): every modelled route must exist in HEY, and every JSON-capable HEY route must be either modelled or listed in spec/excluded-routes.json with a reason. Generated ops that HEY does not serve cannot get in unnoticed.

A handful of services (Clips, Snippets, Workflows, Publications, World, Extenzions, CalendarEvents, and parts of Contacts and Search) still talk to HEY the way the web UI does — form posts, and for a few reads, the HTML page — because those endpoints have no JSON yet. Go covers them through PostForm and its neighbours, Rust through Client::form/Client::send_form, and Kotlin and Swift through client.form/client.sendForm, each with the same hand-written services. They are marked as such in the code and are being replaced as HEY grows JSON for them.

Develop

make ts-install # frozen TypeScript dependency install (npm ci)
make check      # Smithy/drift, Go + Rust + TypeScript + Kotlin + Swift checks and conformance runners

make check is the gate; see AGENTS.md for the pipeline, the exact steps for adding an operation, and the hard rules (never hand-write an API path; every operation needs tests). CONTRIBUTING.md covers the workflow and releases.

About

HEY software dev kit

Resources

Contributing

Security policy

Stars

20 stars

Watchers

2 watching

Forks

Releases

Packages

Used by

Contributors

Languages