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.
go get github.com/basecamp/hey-sdk/go@latestRequires Go 1.26 or newer. The Rust crate's install line is in its section.
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.
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.
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.
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.
Paged reads follow HEY's Link headers automatically, up to WithMaxPages.
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.
[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.
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 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.
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?;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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
make ts-install # frozen TypeScript dependency install (npm ci)
make check # Smithy/drift, Go + Rust + TypeScript + Kotlin + Swift checks and conformance runnersmake 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.