# HEY SDK Go, Rust, TypeScript, Kotlin and Swift SDKs for the [HEY](https://www.hey.com) 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](https://github.com/basecamp/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](#rust), [the Kotlin one](#kotlin) and [the Swift one](#swift) follow it, and each library's own README ([Rust](rust/hey-sdk/README.md), [Kotlin](kotlin/README.md), [Swift](swift/README.md)) goes further. See the [TypeScript guide](typescript/README.md) for installation, Node support, all modeled operations, pagination and examples. TypeScript registry provisioning is pending; [maintainer activation](TYPESCRIPT_RELEASE.md) 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](go/README.md) is also available. ## Install ```bash 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](#install-1). ## Authenticate Static token (scripts, agents, anything that already holds a token): ```go 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 ```go 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. The answer // names the entry that went out and the thread it is on (zero from a HEY that predates them). sent, err := client.Messages().Create(ctx, "Subject", "Body", []string{"someone@example.com"}, nil, nil) if err != nil { return err // refused, or the outcome is unknown: check the thread before sending again } fmt.Println(sent.Id, sent.TopicId, sent.Delayed) // 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}) // Rename a thread by its topic ID. Success is 204 No Content, not a thread read. _ = client.Topics().Rename(ctx, topicID, "Kitchen renovation") // 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: ```go 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: ```go 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. A 422 is `hey.CodeValidation`, its `Message` HEY's reasons joined, and `FieldErrors()` each reason on its own: the `Message`, and the `Field` when HEY named one. ### 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 ```toml [dependencies] hey-sdk = "0.35" tokio = { version = "1", features = ["macros", "rt-multi-thread"] } ``` The crate is [`hey-sdk` on crates.io](https://crates.io/crates/hey-sdk), documented on [docs.rs](https://docs.rs/hey-sdk). To track the repository instead, depend on it at a release tag: `hey-sdk = { git = "https://github.com/basecamp/hey-sdk", tag = "v0.35.0" }`. Requires Rust 1.88 or newer (`rust-version` in `rust/Cargo.toml`, built on exactly that in CI); the crate's [Versioning](rust/hey-sdk/README.md#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: ```rust 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`](rust/hey-sdk/examples/oauth_pkce.rs) is the whole flow. ### Use it ```rust 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: "
Body
".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: ```rust 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. A 422's reasons, one per entry of HEY's `errors` list, are `field_errors()`. ### Pagination Paged reads answer a `Page`, 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`](rust/hey-sdk/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`](rust/hey-sdk/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`](rust/hey-sdk/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 `SensitiveString`s that print as `[REDACTED]`, response bodies are capped, and HTTPS is enforced off localhost. The examples under [`rust/hey-sdk/examples`](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 ```kotlin 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.35.0") } ``` The library is on [GitHub Packages](https://github.com/basecamp/hey-sdk/packages), which wants a token with the `read:packages` scope for every download; [kotlin/README.md](kotlin/README.md#install) 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 ```kotlin 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 ```kotlin 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 = "
Body
", 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 `SensitiveString`s 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 ```swift dependencies: [ .package(url: "https://github.com/basecamp/hey-sdk", from: "0.35.0"), ] ``` 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 ```swift 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 ```swift 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: "
Body
", 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 `SensitiveString`s 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 — 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 ```bash 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](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](CONTRIBUTING.md) covers the workflow and releases.