Migrating

August 22, 2026 · View on GitHub

Upgrade notes for consumers of the Basecamp SDKs. One section per release that breaks something, newest first. Read the section for your version range before you upgrade, not after.

This file exists because the repo generates its release notes from PR labels (see CONTRIBUTING.md). That produces an accurate list of what merged; it cannot tell you which of those changes your code has to react to, or what wrong behaviour you get if you ignore one. This file is that half.


v0.15.0

Go: device-flow and token-exchange requests are address-policed by default (#806)

The compile error, if you get one: none for direct calls. NewExchanger gained a variadic ...ExchangerOption, which is source-compatible everywhere except a function value — var f func(*http.Client) *oauth.Exchanger = oauth.NewExchanger no longer compiles. apidiff reports it as incompatible for that reason.

The behaviour change: PerformDeviceLogin, RequestDeviceAuthorization, PollDeviceToken, and an Exchanger built with a nil client used to post on http.DefaultClient. They now post on a client that judges the endpoint's literal address at dial time against oauth.DefaultIssuerPolicy() — the same policy #804 put on the advertised-issuer metadata fetch — because the token_endpoint and device_authorization_endpoint those requests target may be the ones a discovered issuer's metadata named, and nothing constrains those to the issuer's origin. An endpoint in loopback, RFC 1918, link-local, CGNAT, or IANA special-purpose space is refused before any connection opens, with a non-retryable *basecamp.Error (api_error) that also matches errors.Is(err, surfguard.ErrBlocked). In the poll loop the refusal ends the flow on the first attempt; it is not a timeout and is never backed off.

Wrong behaviour you get if you ignore it: a login or exchange against a hand-configured authorization server on localhost or a private address fails where it used to succeed, and the error names the address policy. Three remedies, in order of preference:

// Re-admit exactly the space you need. AllowLoopback is the only derivation
// that pierces the IANASpecialUse tables; for RFC 1918 build without them.
oauth.PerformDeviceLogin(ctx, cfg, clientID, display,
    oauth.WithDevicePolicy(oauth.DefaultIssuerPolicy().AllowLoopback()))
oauth.NewExchanger(nil, oauth.WithExchangerPolicy(
    surfguard.Policy{}.AllowAllPorts().Allow(netip.MustParsePrefix("10.4.0.0/16"))))

// Carry the requests on your own client — yours, enforcement included.
oauth.PerformDeviceLogin(ctx, cfg, clientID, display, oauth.WithDeviceHTTPClient(hc))
oauth.NewExchanger(hc)

// Restore the old behaviour outright.
oauth.NewExchanger(http.DefaultClient)

If you already pass your own client, nothing changed for you — including the protection. A client handed to WithDeviceHTTPClient or NewExchanger is used as given; the policy is not layered on top, because it lives in the transport's dialer. Compose it in yourself where you can: &http.Client{Transport: oauth.DefaultIssuerPolicy().RoundTripper()}.

Go: resource-first discovery judges the advertised issuer's address (#804)

The compile error, if you get one: none for direct calls. NewDiscoverer gained a variadic ...DiscovererOption — a stored function value (var f func(*http.Client) *oauth.Discoverer = oauth.NewDiscoverer) no longer compiles, the same shape as #806's NewExchanger above.

The behaviour change: DiscoverFromResource's second hop — the metadata fetch of the issuer a protected-resource document advertised — used to ride the client you gave NewDiscoverer. It now rides a shared client built from oauth.DefaultIssuerPolicy() that judges the endpoint's literal address at dial time: an advertised issuer in loopback, RFC 1918, link-local, CGNAT, or any IANA special-purpose space is refused before a connection opens, with a non-retryable ErrInvalidIssuerOrigin that also matches errors.Is(err, surfguard.ErrBlocked). The policy applies on both selection paths, WithExpectedIssuer included — deliberately no exemption. Hop 1, Discover, and DiscoverLaunchpad are unchanged: your client, loopback included.

Wrong behaviour you get if you ignore it: resource-first discovery against an authorization server on localhost or private space fails where it used to succeed — and, quieter, your custom TLS roots, instrumented transport, or proxy no longer carry that one hop (the policy client sets Proxy: nil by construction), so a proxied consumer's discovery hop 2 egresses direct unless redirected back. Remedies, in order of how much policy they keep — split by address class, because they do not reach the same space: WithIssuerPolicy(oauth.DefaultIssuerPolicy().AllowLoopback()) re-admits loopback and nothing else; for RFC 1918 or other special-use space, Allow does not pierce the IANA tables — build the policy without them and admit exactly what you mean, WithIssuerPolicy(surfguard.Policy{}.AllowAllPorts().Allow(netip.MustParsePrefix("10.4.0.0/16"))); then WithIssuerHTTPClient(hc) to supply your own client; then WithoutIssuerPolicy() to opt out entirely.

All SDKs: the signed download hop no longer follows redirects (#805)

DownloadURL (downloadURL, download_url, UploadsService.Download and its siblings) is two hops: an authenticated GET to the API host, which answers with a 302 to a presigned storage URL, then an unauthenticated GET of that URL. The first hop never followed redirects — the SDK reads Location itself. The second hop did, in Go (net/http's default, ten hops), TypeScript (fetch's default, twenty), Python (follow_redirects=True, written out) and Swift (the redirect-following Transport entry point). Kotlin and Ruby never did.

All six now refuse. A redirect — 301, 302, 303, 307 or 308; any other 3xx is the generic non-2xx failure — from the storage host surfaces as the SDK's API error carrying that status — *basecamp.Error with HTTPStatus: 302, BasecampError with code: "api_error" and httpStatus: 302, ApiError with http_status=302, and so on — with a message saying the redirect is not followed, and the Location it named is never dialled. SPEC §14 "Hop-2 Redirect Policy" states the rule and the evidence behind it: Basecamp's storage tier answers presigned GETs from a single endpoint, and two SDKs have shipped a non-following second hop since the download path existed.

Wrong behaviour you get if you ignore it: none against Basecamp. Against another API host whose storage does redirect — a CDN in front of an object store, a multi-region bucket answering with a region redirect — downloads that used to succeed now fail with the redirect's status. There is no knob to re-enable following; the fix is for that host to return the storage URL it actually serves from.

BasecampError.api gained a fifth associated value (#750)

Swift only, and it is a compile error — the one shape of break you cannot miss.

case .api(let message, let status, _, _, _):      // one more `_`
    print("API error (\(status ?? 0)): \(message)")

// …or better, stop matching the case:
if let decodeFailure = error.decodeFailure {
    print("Basecamp sent a body this SDK could not decode: \(decodeFailure)")
}

The case is now api(message: String, httpStatus: Int?, hint: String?, requestId: String?, decodeFailure: (any Error & Sendable)?). Add one more _ to every match and decodeFailure: nil to every construction — or better, stop matching the case and read the new error.decodeFailure property, defined on every BasecampError (nil for all other shapes), which will not move again when a case gains a sixth value. A bare case .api match, and a switch with a default, keep compiling untouched.

What the slot means. It is the response decoder's own refusal, and it is set on exactly two things: the SPEC §6 statusless api_error raised for a 2xx body the model would not decode, and the merge-safe composites' restatement of that same failure with their escape hatch attached. It is nil everywhere else — including the other statusless .api, the pagination same-origin guard, which is a deliberate refusal and not a bad body. The value is a DecodingError for a typed decode and a CocoaError for a body that is not JSON at all, so match the concrete type if you need to tell those apart.

Why the break was worth taking. Swift used to answer "is this a malformed response body?" by looking for the phrase "returned a body that does not decode" inside the message, through an @_spi(Conformance) helper whose own docstring named the reason: .api had no slot to answer it structurally, and statuslessness alone would also match the pagination guard. That made the wording of a sentence the contract — rewording the message moved the answer, and a caller composing their own .api around the phrase produced one. Kotlin has answered the same question structurally since #730. The helper, the phrase constant and the SPI are gone.

Wrong behaviour you get if you ignore it: you cannot ignore it — it does not build. Once it does, nothing else changed: the message still carries the decoder's account of the failure, and the classification (api_error, statusless, non-retryable) is unchanged.

Related, non-breaking, in the other five SDKs. The same question now has the same structural answer everywhere, and none of it needs anything from you: Kotlin's BasecampException.Api.decodeFailure became a public read-only property (it was internal, invisible to a separate Gradle module); Go's two composite decode refusals set Cause, so errors.As reaches *json.UnmarshalTypeError and *json.SyntaxError through Unwrap; Ruby's paginated-page parse failure carries the JSON::ParserError in cause; Python's BasecampError grew a cause property over __cause__, which its refusal sites already set.

TypeScript has one behaviour change. A followed pagination page whose body is not JSON used to escape as a bare SyntaxError — no code, no hint, indistinguishable from a bug in your own code. It is now the same statusless, non-retryable api_error Ruby and Python already raised there, with the SyntaxError in cause and the page number in the message. A catch for SyntaxError around a paginated call stops matching; a catch on BasecampError starts.

TypeScript: four paginated methods now declare the ListResult they already returned (#737)

// before
async listGauges(options?: ListGaugesGaugeOptions): Promise<components["schemas"]["ListGaugesResponseContent"]>
// after
async listGauges(options?: ListGaugesGaugeOptions): Promise<ListResult<components["schemas"]["Gauge"]>>

search.search, gauges.listGauges, gauges.listGaugeNeedles and checkins.reminders are paginated, and at runtime every one has always returned the ListResult the pagination loop builds — their own JSDoc already promised .meta.totalCount. But the generator resolved element type names only through the hand-maintained TYPE_ALIASES map, and these four entities (SearchResult, Gauge, GaugeNeedle, QuestionReminder) were not in it, so the declared type fell back to the bare *ResponseContent array and dropped the wrapper. result.meta.totalCount was a type error on exactly these four methods while every sibling allowed it. The fix is the fallback becoming pagination-aware rather than the map growing four entries, so the next unaliased paginated entity cannot repeat this.

Wrong behaviour you get if you ignore it: none at runtime — the returned object did not change, only the type telling the truth about it. What can stop compiling: a test double or wrapper typed to the old declared signature no longer satisfies the service's, since a bare array is not a ListResult. Reading code only gains: .meta is reachable without the instanceof laundering the old signature forced, and ListResult<T> extends Array<T>, so existing array-typed reads keep compiling.

Swift: every generated model now has a public init (#735)

Not a break — 35 models stop being unconstructible. Swift's implicit memberwise initializer is internal, and the model emitter wrote an explicit public init only for structs with at least one required member. An all-optional model got none, so no code outside the module could construct one — which made two operations unusable, in different ways. updateGaugeNeedle's outer request was constructible — request models get their public init from a second emitter that always wrote one; gaugeNeedle itself is optional and nil-defaulted — but its all-optional GaugeNeedleUpdatePayload was not, so the only callable shape was UpdateGaugeNeedleRequest(gaugeNeedle: nil) — sending the empty {} bc3 rejects with a 400. updateMyPreferences could not be called from outside the module at all: its outer request requires a PreferencesPayload, and that payload was unconstructible.

Every generated model now carries the same-shaped public init the required-member models always had — required parameters take no default, optional ones default to nil — and no existing initializer changed its signature, so there is nothing to migrate. This entry exists because the fix is consumer-visible where the SDK's own test suite could not see it: every test file that imports the SDK module imports it @testable, which raises internal to visible, so constructing these models passed in tests against a surface no consumer had. A plain-import consumer target now builds in CI to keep it that way.

TypeScript and Ruby: the validated maxPages cap can no longer be replaced after construction (1919e77f7)

Four SDKs already stored the validated pagination cap where nothing can replace it after construction — Go's unexported options copy, Kotlin's val, Swift's let, Python's frozen dataclass. The other two only looked capped:

  • TypeScript: maxPages was protected readonly, which is compile-time only, so (svc as any).maxPages = Infinity replaced the validated cap and made the pagination bound unreachable. The cap now lives in a native #private field read directly by both pagination loops, with a protected getter preserving the supported subclass read. The escape hatch stops working: in strict-mode code the assignment throws a TypeError (the property is a getter with no setter), and in sloppy mode it is silently ignored — [[Set]] on an inherited getter-only accessor creates no own property.
  • Ruby: Config#max_pages was a bare attr_accessor, and the HTTP layer reads the config live at every page boundary, so config.max_pages = -1 took effect on the next page fetch. The writer now validates with the same predicate validate! uses, so an invalid assignment raises ArgumentError immediately. Assigning a valid cap still works — the config deliberately stays mutable for the builder-style from_fileload_from_env flow.

Wrong behaviour you get if you ignore it: none, unless you were mutating the cap through one of those two holes to defeat the bound — pass the value at construction (maxPages in the service options, max_pages on the config before use) instead.

Ruby: three crash classes on server-supplied URLs became ApiError refusals (2f21c9de7, 328153020, 478514642)

Three bare commits, one class: a malformed or non-dialable server-supplied URL used to escape as a raw exception from inside URI/Net::HTTP instead of being refused. Pagination's <mailto:>; rel="next" Link header and a Location: mailto: redirect raised URI::InvalidComponentError; a signed download whose hop-2 target was mailto: raised the same thing one frame later; a hostless http:foo redirect Location crashed with a raw ArgumentError ("no host component for URI") from Net::HTTP::Get. All three now raise the SDK's ApiError with a legible refusal message, matching the refusals Go and TypeScript already surfaced on their dial paths. No request is ever made to the rejected target — each error fires before that follow-up is sent, so this was illegibility, not exposure. The request that returned the offending header has of course already happened, and still counts in hooks and request tallies.

Wrong behaviour you get if you ignore it: none, but a rescue for the raw classes (URI::InvalidComponentError, ArgumentError) around pagination or downloads stops seeing them raised from those paths — rescue Basecamp::ApiError instead.

TypeScript: Retry-After parsing is strict, and values it used to honour now back off instead (#564)

Nothing to change, but a throttled client may now wait differently — usually by waiting a sane amount where it previously waited an absurd one.

SPEC §6 says a Retry-After is either delay-seconds (RFC 9110's 1*DIGIT) or an RFC 7231 HTTP-date. TypeScript reached that contract through parseInt and Date.parse, both of which are far wider than the grammar, so four classes of malformed header used to buy a delay. They are now rejected and fall through to the ordinary exponential backoff:

Two things read this header and they did not behave the same before, so the table splits them: the retry wait (both retry loops, which used a bare parseInt with no date branch at all) and the error.retryAfter value exposed on a BasecampError (which used the compliant parser, Date.parse included). Both now come from one parser.

headerretry waitederror.retryAfter wasboth are now
120junk120 s — parseInt reads a prefix120backoff (~1s) / undefined
2099-01-012099 s — read as the number 20992099backoff / undefined
3000junk3000 s3000backoff / undefined
Jan 1 2099backoff — parseInt gave NaN~2.3 billion s, via Date.parsebackoff / undefined
9007199254740993~9.0×10¹⁵ ssamebackoff / undefined
a 400-digit valueInfinityInfinitybackoff / undefined

Note that only the last row ever reached Infinity: an integer merely past Number.MAX_SAFE_INTEGER stayed finite and was honoured as an absurd but real wait. And Jan 1 2099 never bought a long wait — the loops could not see dates at all — though it did report one to any caller reading error.retryAfter.

A leading sign and surrounding whitespace are still accepted (+120 is 120), because every other SDK's integer parser consumes them.

A valid but enormous wait is now clamped, not collapsed. A Retry-After above 2,147,483 seconds (~24.85 days) — whether spelled as seconds or as a far-future HTTP-date — is capped at that value. It is the largest delay a 32-bit millisecond timer can serve, and above it setTimeout does not wait longer: it clamps to 1ms. So a server asking for a month used to get retried in 2ms; it now gets the longest wait the platform can actually schedule. oauth/device.ts already bounded its own Retry-After at the same number.

Wrong behaviour you get if you ignore it: none, but waits move in both directions and it is worth knowing which is which.

Shorter, for rejected values: a malformed header that used to buy 120 or 3000 seconds now costs the ordinary ~1s backoff.

Longer, for valid ones: 0 and negatives used to retry instantly and now back off ~1s, and — the big one — a genuinely oversized Retry-After used to retry after about 1ms and now waits up to 24.85 days. That is not a regression, it is the point: above the 32-bit bound setTimeout does not sleep longer, it clamps to 1ms, so the longest instruction a server could send collapsed into a tight retry loop against an origin already answering 429 — precisely the failure SPEC §7's backoff ceiling exists to prevent. If your code assumed a retry would always come back quickly, that assumption was resting on the bug.

One clarification, because it changes who is affected: the retry loops never had a date branch before this PR, so an HTTP-date could not make the SDK sleep for decades. It could only be reported that way through BasecampError.retryAfter, which is metadata — so anything that read that property and slept on it saw the decades; anything that let the SDK retry saw backoff.

If you were relying on the old leniency — e.g. an internal service emitting Retry-After: 30s or an ISO-8601 timestamp — send 1*DIGIT seconds or an IMF-fixdate (Sun, 06 Nov 1994 08:49:37 GMT) instead. The obsolete RFC 850 and asctime date forms are not accepted, matching Ruby and Swift; Go, Python and Kotlin remain more permissive there, so a date form one of them takes may still be refused here.

Kotlin: Retry-After now honours the HTTP-date form it used to ignore (#564)

A behaviour gain, and worth knowing if you compensated for the gap.

parseRetryAfter was toIntOrNull() and nothing else, so Kotlin was the one SDK of six that ignored SPEC §6 step 2 entirely. A Retry-After that names an instant rather than a count of seconds — the IMF-fixdate form, shaped like Sun, 06 Nov 1994 08:49:37 GMT — parsed as nothing however far in the future that instant was, and the client backed off ~1s instead of waiting until it. It now parses that form and waits max(0, date - now()) seconds whenever the instant is still ahead, matching the other five. A date already in the past still yields no delay, in Kotlin as everywhere else — that is step 2 working, not the old gap.

This reaches every consumer of the parser at once — the shared HTTP client, the service base, and both download hops — since they all routed through the one function already.

Wrong behaviour you get if you ignore it: none, but a caller that added its own Retry-After handling to work around the gap will now double-count the delay: the SDK sleeps the server-directed interval and your wrapper sleeps it again. Drop the workaround. Note the parsed value is also attached to the raised exception on any status, while only a 429 turns it into the SDK's sleep — which statuses honour it is divergent across the six SDKs and is tracked separately in #775.

Two changes to the same knob, one of them a bug fix you may feel in production.

initGeneratedClient never passed the client's retry settings to the generated client. It was constructed with only WithHTTPClient and WithRequestEditorFn, so its retry loop ran on generated.DefaultRetryConfig{MaxRetries: 3} regardless of what WithMaxRetries(n) said. Only the raw Get/GetAll and download paths, which run the hand-written loop, honoured the setting — so WithMaxRetries' doc comment promised "GET requests" and delivered a subset of them.

Scope: retry-eligible typed operations only. doWithRetry forces maxAttempts to 1 for a non-idempotent operation before consulting the retry config, and then clamps to the operation's x-basecamp-retry ceiling. So CreateTodo and every other non-idempotent POST always made exactly one attempt and are untouched by this, and the eleven max: 2 operations were capped at two rather than three. What was wrong is that eligible operations used min(3, op_max) — the generated default — where they should have used min(your_cap, op_max).

// Before: three attempts on a 503, despite the cap. After: one.
c := basecamp.NewClient(cfg, tp, basecamp.WithMaxRetries(1))
_, err := c.ForAccount("999").Projects().Get(ctx, 12345)

Wrong behaviour you get if you ignore it: none to fix — this makes the SDK obey a setting it was already documented to obey. But if you lowered the cap to fail fast and tuned timeouts around the attempts you were actually getting, your effective worst-case latency just dropped on those operations, and a caller relying on the accidental extra attempts to ride out flapping upstreams will now see the first failure surface sooner. Raise the cap deliberately if you want the old count.

BaseDelay carries over too, clamped at MaxBackoffDelay (30s) — the generated loop uses it verbatim for its first sleep and applies MaxDelay only after multiplying, so an unclamped WithBaseDelay(10*time.Minute) would have stalled a typed operation for ten minutes, against SPEC §7's rule that no single computed backoff exceeds the ceiling. A BaseDelay of 0 now reaches typed operations as 0 rather than being replaced by the generated 1s default. MaxDelay and Multiplier keep the generated defaults, HTTPOptions having no counterpart for either.

WithMaxRetries(0) no longer panics. NewClient accepted only n >= 1 and panicked "basecamp: max retries must be at least 1". Zero is now legal and means "no retries — exactly one attempt", which is what the other SDKs with a numeric cap already did and what SPEC §14's attempt-budget table already described; the GET and download loops floor the cap at one attempt so a request is always made. Only a negative cap now panics, with a new message: "basecamp: max retries must not be negative". Code matching on the old string (a recover that inspects the message, as the conformance runner did) needs updating; code that simply never passed 0 is unaffected.

Go: Error gained RetryAfter mid-struct, and raw GETs now sleep it (#795)

The compile error, if you get one: an unkeyed composite literal basecamp.Error{code, msg, hint, fieldErrors, status, retryable, reqID, cause} no longer compiles — the field is inserted between Retryable and RequestID, so the eight-value form is now too short. Same break FieldErrors caused in #541 (below, under Go → Behavioural); the remedy is the same, and permanent: use keyed fields.

// before
err := &basecamp.Error{basecamp.CodeRateLimit, "Rate limited", "", nil, 429, true, "", nil}
// after — and it will not break again
err := &basecamp.Error{Code: basecamp.CodeRateLimit, Message: "Rate limited", HTTPStatus: 429, Retryable: true}

apidiff reports this as compatible, and it is right about what it measures: the field is additive to the exported API surface. Unkeyed literals are a source-compatibility hazard the tool does not model, which is why this note exists rather than the gate catching it.

The behaviour change: Client.Get/GetAll and the raw escape hatch used to back off on their own local curve after a 429 even when the server named a delay, because the loop read that delay off a type nothing in the package ever constructed. They now sleep the server's Retry-After — both wire forms, delta-seconds and HTTP-date — in place of the backoff, with no jitter and no ceiling beyond what the host can represent: a value the parser holds but the host cannot schedule saturates at 2147483647 seconds (~68 years) rather than wrapping negative, and that figure does not vary by architecture. A value too large for the parser's own int64 is treated as malformed instead and falls through to the backoff curve, as it always did. That split is Go's: SPEC §6's parsing algorithm says only to parse a positive integer, #793 states the two-tier rule (unrepresentable → malformed, unschedulable → saturate) in §6 "Retry-After Honouring", and #799 tracks the cross-SDK convergence on over-range values, which the six SDKs still answer differently.

Two behaviours changed for DownloadURL and the rate-limiter hook as well, because all three paths share parseRetryAfter: an HTTP-date's sub-second remainder now rounds up instead of truncating, so a date less than a second away yields a one-second wait where it used to yield "no value" and fall onto the backoff curve; and a delta-seconds above the schedulable ceiling saturates instead of wrapping. The wire operations those paths perform are unchanged, and they already honoured the header on 429 — it is what the header parses to that moved. Typed service methods run the generated retry loop, which has its own copy of the parse and is untouched (#798).

Wrong behaviour you get if you ignore it: none, but the wait between attempts on a throttled account can now be seconds or minutes where it used to be milliseconds, so a caller that sized a context timeout against the old backoff may now hit it. The wait observes cancellation — the loop selects on ctx.Done() — so cancelling is the escape. If you would rather reschedule the work yourself, the *Error carries RetryAfter — but the loop wraps it with fmt.Errorf on exhaustion, even at a cap of one attempt, so a type assertion on the returned error fails. Extract it with errors.As:

var apiErr *basecamp.Error
if errors.As(err, &apiErr) && apiErr.RetryAfter > 0 {
    // reschedule after apiErr.RetryAfter seconds
}

Go: a body that never arrived is no longer stamped as permanently malformed (#773)

Nothing to change, but two error classifications move on the paths that read a response body by hand: Documents.Get and the merge-safe Update/ Edit built on it, and Schedules.GetEntry and the carve-out-aware UpdateEntry/EditEntry built on that — direct getter calls are in scope, not just the composites.

Go streams response bodies, so the io.ReadAll inside the generated parser is where a truncated body, a reset connection or an expired deadline actually surfaces — and these paths rendered every parser failure as the SPEC §6 malformed-body shape: a statusless *basecamp.Error with CodeAPI and Retryable: false. A transient network failure read as a permanently malformed body. The body read is now marked at the transport layer, and a failure there returns the transport's own error verbatim — the way every other transport failure on these paths already did.

Wrong behaviour you get if you ignore it: none, but two things you may have matched on move. errors.As(err, &apiErr) no longer matches a mid-body network failure on these paths — reach for net.Error, context.DeadlineExceeded or io.ErrUnexpectedEOF, whichever you actually mean — and a retry policy keyed on the SDK error's Retryable field no longer sees a hard false for a failure that was never the body's fault. A genuinely malformed body is unchanged: still the statusless api_error, with the JSON error reachable through Cause.

Go: an absent expiry reads as absent, not as an instant (#662)

Two silent behavior changes on AuthorizationInfo.ExpiresAt (FlexTime), and neither gives you a compile error:

  • A wire expires_at: 0 now decodes to the zero time. Previously it decoded to time.Unix(0, 0) — a valid 1970 date with IsZero() == false, so "no expiry" read as "expired 56 years ago". No production issuer has ever sent 0 (BC3 tokens validate presence; legacy Signal tokens self-default an expiry), so this is hardening against the RFC 7591 collision — 0 means "never expires" in bc3's own client_secret_expires_at — not a live-bug fix. Code that deliberately round-tripped 0 through FlexTime gets the zero time back instead.
  • A zero FlexTime marshals as null. Previously it marshaled as the fabricated instant "0001-01-01T00:00:00Z", indistinguishable from data the server sent. If you re-serialize AuthorizationInfo and consume expires_at downstream, expect null where that sentinel used to be.

New, not breaking: info.Expiry() (time.Time, bool) is the documented front door — ok is false when the document stated no expiry (absent field, explicit null, or a wire 0 alike; all defensive, per the above — no production issuer emits any of them). Prefer it over reading ExpiresAt directly.

Go: TimelineEventData.StartsAt/EndsAt became *types.FlexibleTime

The same class as v0.13.0's four Go pointer entries: the generated counterpart is *types.FlexibleTime (the bounds are required-and-nullable — schedule_entry_* events always carry them, but the value may be null), and the hand-written struct flattened them to value types, fabricating 0001-01-01T00:00:00Z for a null bound on re-marshal.

This compiles unchanged and panics at runtime on the wrong payload. Go promotes value-receiver methods through the pointer, so ev.Data.StartsAt.IsZero() still builds — and nil-panics when the API sent null. Nil-check first:

// Before
if !ev.Data.StartsAt.IsZero() { start := ev.Data.StartsAt.Time; ... }

// After
if ev.Data.StartsAt != nil { start := ev.Data.StartsAt.Time; ... }

A nil bound re-marshals as null (the key stays, matching the wire contract); a null bound previously decoded to the zero time, so IsZero()-based absence checks translate to nil checks.

Kotlin: search.search returns ListResult<SearchResult>, not ListResult<JsonElement> (#717)

// before
suspend fun search(q: String, options: SearchOptions? = null): ListResult<JsonElement>
// after
suspend fun search(q: String, options: SearchOptions? = null): ListResult<SearchResult>

Kotlin was the last tier where the search projection was untyped, which meant the special-branch modelling in #651 enforced nothing here: a JsonElement decodes any body whatsoever, so a spec that lied about the shape could not fail. SearchResult and SearchResultAttachment are now generated into com.basecamp.sdk.generated.models.

Every .jsonObject["…"] navigation over a search hit stops compiling — the same break reports.upcoming took at v0.13.0, and taken for the same reason. Read the members instead:

// before
val title = hit.jsonObject["title"]?.jsonPrimitive?.contentOrNull
// after
val title = hit.title

Wrong behaviour you get if you ignore it: none silently — this is a compile error at every call site that inspected a hit. The runtime change is the one worth knowing about: SearchResult declares content and description as required-and-nullable (no default), so a body omitting either now throws MissingFieldException where the untyped surface accepted it. bc3 cannot produce such a body — api/searches/show.json.jbuilder nil-overwrites both on every branch, attachment hits included — so this is class B by this document's rule.

Two member-level traps, both consequences of #651 rather than of the retype: id, title, type, url and app_url are nullable, because the file-attachment branch omits all five; and width/height decode through FlexibleIntSerializer, because bc3 may float-spell them (1920.0).

Tool.name and Tool.enabled are not on the wire at all; the seven keys that are emitted are now modeled (#650)

A dock tool's projection is the bare recordings/recording partial — app/views/api/docks/tools/show.json.jbuilder renders it and adds nothing. That partial emits no name and no enabled. Sibling dock-tool projections (Todoset, Questionnaire) do carry a name, but from their own recordable partial, which tools do not have; the name/enabled pair the tools docs describe belongs to the project dock array, which the SDK already models separately and correctly as DockItem.

Both were @required. In Swift that made Tool undecodable: GetTool, CreateTool and UpdateTool threw DecodingError.keyNotFound on every real response. Both are now optional, and the seven keys bc3 does emit are modeled: type, visible_to_clients, inherits_status, bookmark_url, subscription_url, parent and creator. Derive the member delta from the spec rather than trusting this prose:

git show v0.14.0:openapi.json | jq '.components.schemas.Tool | {required, props: (.properties|keys|length)}'
jq '.components.schemas.Tool | {required, props: (.properties|keys|length)}' openapi.json

Three of the emitted keys are conditional on the wire — two of the seven new ones (subscription_url, parent) plus position, which the spec modeled before this change — and the conditions are not guesses: they are the partial's own ifs:

  • subscription_url — only when the recordable is subscribable. Chat::Transcript, Todoset and Kanban::Board override Recordable#subscribable?; Vault, Message::Board, Schedule, Inbox and Questionnaire do not, and get no key.
  • position — only when recording.positioned?. A tool disabled in the dock is removed from it, not deleted, so position is absent. That is what replaces enabled: false, which never existed. Positioning is independent of dockedness, though, so the inference runs only in that direction: a nested vault is not docked and is still positioned.
  • parent — only when !recording.docked?. A docked tool has none, but the dock-tool lookup scopes by recordable type rather than dock membership, so a vault nested inside another vault resolves through GET /dock/tools/:id and does carry a parent.
SDKwasnow
Go pkg/generatedName string, Enabled bool*string, *bool — value use is a compile error; plus Creator Person, Type, VisibleToClients, InheritsStatus (value) and BookmarkUrl, SubscriptionUrl, Parent (pointers)
Go pkg/basecampName string, Enabled bool on the wrapper*string, *bool, always nil — value use is a compile error. Deliberately not value-typed with omitempty: ""/false would read like a real answer. Wrapper gains the same seven
Swiftlet name: String, let enabled: Boolvar name: String?, var enabled: Bool?decoding a real response now succeeds instead of throwing. New non-optional creator, type, visibleToClients, inheritsStatus; the memberwise init argument list changed
TypeScriptname: string, enabled: booleanname?: string, enabled?: boolean — the read itself still compiles, at type string | undefined; only using one where a concrete string/boolean is required needs a guard or a default. type, visible_to_clients, inherits_status, creator are now required
Pythonname/enabled required in the TypedDictNotRequired[...]; type, visible_to_clients, inherits_status, creator required — type-checker-visible only, nothing changes at runtime
RubyTypes::Tool.required_fields included :enabled, :namereturns [:created_at, :creator, :id, :inherits_status, :title, :type, :updated_at, :visible_to_clients]; readers for every new key
Kotlinval name: String, val enabled: BooleanString?/Boolean? (defaulted null); new non-null visibleToClients, inheritsStatus, type, creator

Wrong behaviour you get if you ignore it: in Swift, none — but that is not the same as no work. Reading a Tool gets strictly better (the fix removes a throw that fired on every call, leaving two optionals you could never read anyway), while constructing one is a compile error until you pass the four new non-defaulted arguments the memberwise init gained: creator, type, visibleToClients, inheritsStatus. Test doubles and fixtures are where that lands. Everywhere else the trap is enabled, and it does not look the same in each SDK, because the key was never sent at all:

  • Go reads false for every tool, enabled or not — the wrapper's Enabled is now *bool precisely so this reads as nil instead.
  • Ruby reads nil (Types::Tool#enabled, and result["enabled"] on the raw hash).
  • Python services return the raw response dict, so result["enabled"] raises KeyError — it is result.get("enabled") that returns None.
  • TypeScript reads undefined.

For a docked tool, use the absence of position — or the project's dock array, which really does carry enabled — to tell a disabled tool from an enabled one.

Kotlin's four new non-null members are class B by this document's rule: a response omitting one fails to deserialize. It needs a payload bc3 cannot produce — the partial emits all four unconditionally, and the five sibling dock-tool projections have modeled them @required all along — but a hand-written test stub that predates this change will hit it, which is the realistic way to meet it.

Kotlin and Swift: a malformed 2xx body raises an SDK error, not the decoder's (#604)

Class B — compiles, then raises a different error type on the wrong payload.

Both SDKs ran encode → URL build → auth → transport → status check → decode inside one try/do per request primitive, with a terminal catch that mapped nothing. A response that did not decode therefore arrived as the decoder's own exception — indistinguishable from the auth strategy throwing or the socket dropping, and invisible to a caller catching the SDK's error type. Only the decode expression is wrapped now, in every request primitive — so every operation's response decode, not just the §18 composites that already did this by hand.

SDKwasnow
Kotlinkotlinx.serialization.SerializationException (incl. MissingFieldException)BasecampException.Api with httpStatus == null, retryable == false, and the SerializationException in decodeFailure — the discriminator (#750); mirrored in cause, which is explicitly not one
SwiftDecodingError — and, on the wrapped-list path, a raw NSError from JSONSerialization for a body that is not JSON at allBasecampError.api(message:httpStatus:hint:requestId:decodeFailure:) with httpStatus == nil (so isRetryable == false), the underlying error's description interpolated into message and the error itself in decodeFailure (see above)
Go, TypeScript, Ruby, Pythonunchangedunchanged

Wrong behaviour you get if you ignore it: a catch (e: SerializationException) or catch let error as DecodingError around an SDK call stops matching. The statusless api_error shape is the one SPEC §6 already defined for a malformed 2xx body, so code that switches on the SDK's own error type needs no change — and now sees a failure it previously missed entirely.

What did not move: an auth-strategy throw, a transport failure and a request-body encoding failure all still surface exactly as before. In Kotlin that distinction is load-bearing — the request body is serialized inside the same try, and throws the same SerializationException type the decoder does.

BasecampError.api did gain a slot in the end, and it is a compile break — see BasecampError.api gained a fifth associated value above. It ships in this same release, so there is one migration to do, not two.

GetPersonProgress is included (#728). Every malformed wrapper now raises the statusless api_error. What it raised before differs per body and per SDK far more than a sentence can carry, so here is the whole thing — one row per body, taken from the tests in DecodeIsolationTest.kt and DecodeIsolationTests.swift, each of which was run against the pre-change code to get the left-hand columns:

bodyKotlin, beforeSwift, before
events absent, person validNullPointerExceptionsucceeded, events: []
person absent, events validNullPointerExceptionraw DecodingError.keyNotFound
person wrong-typedraw SerializationExceptionraw DecodingError.typeMismatch
events a JSON objectIllegalArgumentExceptionalready the api_error; the message did not name events
events a scalarIllegalArgumentExceptionuncaught NSInvalidArgumentException — not a Swift error, no catch reaches it
body not a JSON objectIllegalArgumentExceptionraw DecodingError.typeMismatch from the wrapper decode

Two rows are the ones to read closely, because they are not merely a changed error type. Swift row 1 is the only silent success in this whole issue: with person valid and only events absent, nothing downstream objected, so the SDK reported a zero-event read of a response it had not understood. Swift row 5 was not an error at all but a process abort, because a scalar is not valid input to JSONSerialization.data(withJSONObject:).

Two mechanisms produced the rest, and they are worth telling apart. The person decode ran outside the boundary — generated code, after the primitive returned — so nothing could have mapped it, in either SDK. The events parse always ran inside it, and Kotlin leaked anyway, because ["events"]!!.jsonArray throws NullPointerException and IllegalArgumentException and the mapping catches neither, on purpose: catching them would swallow every !! and require() in the SDK.

BC3 settles which reading is right: app/views/api/users/timelines/show.json.jbuilder writes person and events unconditionally, so an absent one is a malformed body and never an empty result.

GetPersonProgressResponseContent.person and .events became required. The model now says what the jbuilder does. Kotlin and Swift already enforced it at runtime; the model is where the other four have to say it, because Go typed-decodes this envelope but encoding/json has no notion of a required member, and TypeScript, Ruby and Python do not typed-decode it at all.

Class B, and in more places than construction. person?/events?person/events in TypeScript's schema.d.ts and in Python's TypedDict (NotRequired gone); Person *PersonPerson Person in Go; var …? with a zero-argument initlet with a two-argument one in Swift. What can stop compiling, or stop type-checking:

breaks on
TypeScriptan object literal typed as this schema that omits either key
Pythona TypedDict literal missing either key, under a type checker
Goresp.JSON200.Person != nil, *resp.JSON200.Person, or assigning a *Person
SwiftGetPersonProgressResponseContent(), if let/?. on either member, or assigning after construction (they are let now)
Rubynothing — its generated types carry no per-member optionality

The SDKs' own PersonProgress surfaces are unchanged, Go's included: it still returns *Person.

Only if you subclass BaseService directly: requestPaginatedWrapped no longer hands back the first page's raw body for you to decode afterwards. It takes the wrapper decode as a trailing closure and returns whatever that closure returns, which is what puts the decode inside the mapping above. Generated services are its only callers in this repo.

SearchResult lost five required members and gained the special-branch keys (#651)

BC3's search projection special-cases four result branches — chat lines, kanban (card table) lists, file attachments and gauge needles — and the file-attachment branch writes its own projection with none of the top-level id/title/type/url/app_url keys. Those five are now optional, and the branches' keys are modeled: the attachment branch's ten file keys (filenameapp_download_url), the kanban list's (subscribers, color, cards_count, comment_count, cards_url, on_hold), the gauge needle's (color, position, comment_count), the chat line kinds' (language, image_url, sound_url), the shared envelope's (subscription_url, position, comments_count/_url, boosts_count/_url), and a typed attachments array. Derive the member delta from the spec rather than trusting this prose:

git show v0.14.0:openapi.json | jq '.components.schemas.SearchResult | {required, props: (.properties|keys|length)}'
jq '.components.schemas.SearchResult | {required, props: (.properties|keys|length)}' openapi.json

content and description stay required-and-nullable — the show template nil-overwrites them on every branch, attachment hits included.

SDKwasnow
Go pkg/generatedId int64; Title, Type, Url, AppUrl string*int64 / *string — value use is a compile error
Go pkg/basecampvalue fields on the wrapperunchanged types, zero-valued on a file-attachment hit; new fields + SearchResultAttachment
Swiftlet id: Int; let title/type/url/appUrl: Stringoptionals — non-optional use is a compile error
TypeScriptid: number, title/type/url/app_url: stringid?: number, … — compile error under strictNullChecks
Pythonid/title/type/url/app_url required in the TypedDictNotRequired[...] — type-checker-visible only, nothing changes at runtime
RubyTypes::SearchResult.required_fields returned all sevenreturns [:content, :description]; readers for every new key
Kotlinuntyped (JsonElement)typed — see #717 above, which lands in the same release

Wrong behaviour you get if you ignore it: code that renders search hits by id/title/type shows a file-attachment hit as a blank row (Go wrapper, Ruby, Python — nothing raises), or crashes on a nil unwrap (Swift force-unwraps, Go pkg/generated derefs). Recognize a file-attachment hit by the absence of type and the presence of filename.

The attachments key was previously documented away as redundant — that was wrong for chat upload lines. A chat upload line's attachments is a bespoke six-key aggregate (title, url, filename, content_type, byte_size, download_url) that does not match RichTextAttachment — no id, no sgid, no preview fields. The new SearchResultAttachment element type is the optional-field superset of both wire variants, with only the four keys both always emit required.

MyAssignmentAssignee / OutOfOfficePerson: name and avatar_url became required (#659)

Both shapes model bc3's people/_person_minimal.json.jbuilder, which renders id, name and avatar_url unconditionally — the same partial UpcomingSchedulePerson already models with all three required. Only id was required here; now all three are.

SDKwasnow
Go pkg/generatedName, AvatarUrl *stringstring — pointer use (*a.Name, nil-checks) is a compile error
Go pkg/basecampown decode structsunchanged
Swiftvar name: String?, var avatarUrl: String?let name: String, let avatarUrl: String — optional-chaining and the old memberwise init(id:avatarUrl:name:) with defaulted nils are compile errors; decode of a payload missing either key now throws
TypeScriptname?: string, avatar_url?: stringname: string, avatar_url: string — removes the need for !/guards; only breaks code constructing the type
PythonNotRequired[str]str (required in the TypedDict) — type-checker-visible only
Rubyrequired_fields returned [:id]returns [:avatar_url, :id, :name]
Kotlinuntyped (JsonElement)unchanged

The Swift decode-throw is the only runtime face, and it needs a response bc3 cannot produce (the partial has no conditional keys) — class B by this document's rule.

v0.14.0

Breaking in Go and in the shape every SDK decodes from GET /uploads/{id}/versions.json.

Operation inventory: 249 → 250CreateUploadVersion. Derive both ends rather than trusting either number:

git show v0.13.0:openapi.json | jq '[.paths[]|keys[]]|length'
jq '[.paths[]|keys[]]|length' openapi.json

ListUploadVersions returns versions, not uploads (#649)

The endpoint has always returned events. The spec declared uploads: UploadList anyway, and 11 of Upload's 14 required members are absent from every response — which is why the CLI's versions command and the MCP server's list_upload_versions printed blank fields rather than failing. The output is now versions: UploadVersionList.

SDKwasnow
GoUploadVersionListResult.Versions []Upload[]UploadVersion
TypeScriptListResult<Upload>ListResult<UploadVersion>
SwiftListResult<Upload>ListResult<UploadVersion>
KotlinListResult<Upload>ListResult<UploadVersion>
Ruby / Pythonparsed body, unchanged at runtimefields differ, see below

The four typed SDKs keep their ListResult wrapper, so .meta.totalCount and the pagination surface are untouched; only the element type changes. Member access moves with it — version.filename becomes version.upload?.filename, and the event's own action, createdAt and creator sit alongside.

In Ruby and Python the compiler will not catch this. Nothing changes in the type; what changes is which keys are actually there. Code reading version["filename"] was reading a key the server never sent and getting nil — it now reads version["upload"]["filename"], and the event's own metadata (action, created_at, creator) is available where it previously looked like a partly-empty upload.

A version carries upload only when its recordable still resolves; a deleted file leaves the event behind with no upload at all. Check before dereferencing. action is created, active (the publication) or blob_changed (a file replacement). To list the file's past versions, take the entries that carry an upload with current == false — not the ones with action == "blob_changed", which drops the original (it arrives as created or active) and keeps the current file. The per-version download_url serves that version's bytes; the upload's own always serves the latest.

Go: UpdateUploadRequest.Description became *string

Tri-state, following UpdateGaugeNeedleRequest.Description (#560): nil leaves it untouched, basecamp.Ptr("") clears it, basecamp.Ptr(v) sets it. Previously a plain string behind a zero-value guard, so "" read as unset and clearing a description through Update was unreachable — the divergence SPEC §5 documented.

// Before — compiled, and silently did nothing to the description.
svc.Update(ctx, id, &UpdateUploadRequest{Description: ""})

// After — clears it.
svc.Update(ctx, id, &UpdateUploadRequest{Description: basecamp.Ptr("")})

// After — leaves it alone.
svc.Update(ctx, id, &UpdateUploadRequest{BaseName: "renamed"})

The compiler catches this one: Description: "text" no longer type-checks. Wrap it in basecamp.Ptr.

BaseName is deliberately still a plain string on both this and CreateUploadVersionRequest. Upload#base_name= guards on new_base_name.present?, so "" and absent are the same write server-side — there is no third state for a pointer to express.

New: UploadsService.CreateVersion and a 507 error code

Not breaking, but the reason for the above. POST /uploads/{id}/versions.json replaces an upload's file in place, keeping the recording's id, URL and comments, so a published link keeps working — which CreateUpload cannot do.

A 507 Insufficient Storage now maps to the new limit_exceeded code (exit code 10) instead of api_error. If you branch on api_error to decide whether to back off, a limit failure no longer lands in that branch — which is the point: it was reported as retryable, and no retry can satisfy a plan limit.

The mapping is by status, not by operation, so it reaches every 507 the spec declares — all eight, across three different limits:

OperationsLimitError shape
CreateUpload, CreateUploadVersion, CreateAttachment, CreateCampfireUploadfile storageStorageLimitError (new)
CreateProject, UnarchiveProjectproject countProjectLimitError (v0.13.0)
CreateWebhook, UpdateWebhookwebhook countWebhookLimitError (pre-existing)

Only the first row is new surface. The other four operations already returned 507 and already reported it as a retryable api_error; they are reclassified here too, so webhook and project callers need the same new branch even though nothing about those endpoints changed. Derive the list rather than trusting it:

jq -r '.paths[]|to_entries[]|select(.value.responses."507")|.value.operationId' openapi.json

The new error code is source-breaking in four SDKs

Adding a member to a closed type breaks exhaustive handling, so this is not merely behavioural:

SDKwhat changedhow it breaks
TypeScriptErrorCode union gains "limit_exceeded"a Record<ErrorCode, T> map, or a switch the compiler checks for exhaustiveness, stops compiling until it has a branch
SwiftBasecampError gains case limitExceededa switch over the enum without a default stops compiling
KotlinBasecampException gains LimitExceededa when over the sealed class used as an expression stops compiling
PythonErrorCode (a StrEnum) gains LIMIT_EXCEEDEDa match over it ending in typing.assert_never stops type-checking — mypy reports the new member as unhandled

Python's break needs a type-checker to surface, not an interpreter: the module imports and runs either way. If your CI runs mypy — this package does — it fails there rather than at import, which makes it easier to miss in review and no less of a break.

Go and Ruby take a new constant rather than a new variant, so neither breaks a build — which is exactly why they need reading for: a case or when falling through to a default arm now routes storage and project limits wherever that default goes.

Add a limit_exceeded branch that surfaces the limit to the user and does not retry. This SDK's own Kotlin test suite hit the compile error, which is what the exhaustive when in ErrorTest exists to produce.


v0.13.0

Breaking across all six SDKs — Go, TypeScript, Python, Ruby, Kotlin, Swift.

Read Breaks your compiler will not catch first. A large share of this release survives a clean build. Most of that share gives you no signal at all — the call keeps working against a live server and does something different. A smaller part compiles and then panics or raises, but only on a payload of one particular shape — and the shape is not the same one in every SDK. The four Go entries need a field to be absent; Ruby's and Kotlin's need theirs to be present, and both stay quiet when it is nil. A fixture built for one direction proves nothing about the other, and either way it passes your tests and fails in production.

SDKno signal at allfails at runtime
Go124
Swift100
TypeScript90
Python80
Ruby101
Kotlin61

61 breaks the compiler will not catch, across the six: 55 with no signal at all and 6 that fail at runtime. These are counts at 9a819e44d — the last commit of release content, and the baseline every count here was measured against, not the commit the tag is cut from. v0.13.0 is tagged from main after this guide merges, so the tagged tree contains this file; the counts carry over unchanged because nothing described here is still in flight.

#637 does not add a row to either column, despite landing after the first draft of this table. It made Todolist.color and .comments_app_url required — but neither member existed on Todolist at v0.12.0 in any SDK. Both arrived earlier in this same release with #628, so from a v0.12.0 consumer's position there is no member that changed from optional to required; there are two new members that happen to be required from the start. It reshapes the #628 break rather than adding one, and that is where this guide documents it.

#681 does break TypeScript, and it is the one change here measured past the baseline this document states. It adds nothing to either column — it is five required-to-optional field relaxations, which tsc catches under strictNullChecks — so the 61/55/6 totals below are unmoved. See Five Identity and AuthorizedAccount fields became optional.

Every claim below was read out of git diff v0.12.0..main, not out of a PR body.

As of 9a819e44d. Base v0.12.0 = 7e2925d25. Every count in this document is a measurement at that commit, not a constant. If you are reading this from a later tag, re-run the derivations below — they are cheap, and a hand-incremented count is how these go wrong. The release spans 64 merged pull requests, 16 of them labelled breaking:

# Ask which PRs are IN the range, rather than counting commits in it. Two
# traps this avoids, both of which produced a wrong number here first:
#
#   1. A merge-TIMESTAMP filter is off by one at the boundary. #556's squash
#      commit IS `7e2925d25`, the commit v0.12.0 tags, and its `mergedAt`
#      lands a moment after that commit's own timestamp — so
#      `mergedAt > tag_time` credits this release with a PR that shipped in
#      the last one.
#   2. `git log v0.12.0..HEAD | wc -l` counts COMMITS, which equals PRs only
#      while every commit is a squash merge. The release-prep commit is
#      pushed directly to main and is not a PR, so that count runs one high
#      from the moment the version is bumped.
#
#   3. Too small a `--limit`. `gh pr list` orders by CREATED, not merged, so
#      a long-open PR that merged late sits far down the list. At `--limit
#      300` an old-but-recently-merged PR drops off the end and is silently
#      uncounted. Ask for more than you need; the filtering is done below.
#
# Reachable from origin/main and not from v0.12.0 is the definition; apply it
# to merge commits of PRs and none of the three traps applies. Read from
# origin/main rather than HEAD — a stale checkout reported an inventory two
# operations behind what was actually on main.
gh pr list --repo basecamp/basecamp-sdk --state merged --limit 1000 \
  --json mergeCommit --jq '.[].mergeCommit.oid' |
  while read sha; do
    git merge-base --is-ancestor "$sha" origin/main 2>/dev/null &&
    ! git merge-base --is-ancestor "$sha" v0.12.0 2>/dev/null && echo "$sha"
  done | wc -l

# Same rule for the breaking subset.
gh pr list --repo basecamp/basecamp-sdk --state merged --label breaking \
  --limit 1000 --json mergeCommit --jq '.[].mergeCommit.oid' |
  while read sha; do
    git merge-base --is-ancestor "$sha" origin/main 2>/dev/null &&
    ! git merge-base --is-ancestor "$sha" v0.12.0 2>/dev/null && echo "$sha"
  done | wc -l

A labelled PR is not the same unit as an entry below: one PR can break four SDKs and one SDK can carry two entries from the same PR, which is why the per-SDK columns are larger than 16.

What shipped

Operation inventory: 238 → 249. Derive both ends rather than trusting either number:

# at the tip
python3 -c "import json;d=json.load(open('openapi.json'));\
print(sum(1 for p,v in d['paths'].items() for m in v if m in ('get','post','put','patch','delete')))"

# at the previous release, without checking it out
git show v0.12.0:openapi.json | python3 -c "import json,sys;d=json.load(sys.stdin);\
print(sum(1 for p,v in d['paths'].items() for m in v if m in ('get','post','put','patch','delete')))"

Sixteen operation IDs added, five removed. At the level of capability that is fourteen additions, three removals and two renames:

Added (14)Folders — ListFolders, GetFolder, CreateFolder, UpdateFolder, DeleteFolder (#593); DestroyTimesheetEntry (#626); cloud files — GetCloudFile, CreateCloudFile, UpdateCloudFile (#629); Google documents — GetGoogleDocument, CreateGoogleDocument, UpdateGoogleDocument (#629); project status — ArchiveProject, UnarchiveProject (#679)
Renamed (2)UpdateDocumentReplaceDocument (#601); UpdateScheduleEntryReplaceScheduleEntry (#632). Same route, same verb, honest name.
Removed (3)GetRecording, TrashTodo, CreateForwardReply (#619)

The Folders operations are worth a second look before you assume the name maps to the route: they are drawn at /{accountId}/stacks(.json), not /folders.

Eleven operations kept their ID and moved route. Nine gained a /buckets/{bucketId} prefix (#619); ListForwards and RepositionTodolistGroup had their spelling corrected (#586). The inventory count is unchanged and every caller breaks. See Route corrections.

Three update methods stopped being sparse PUTs and became read-modify-write composites — the largest behavioural change in the release. See Merge-safe composites.

No surviving operation's retry, idempotency or pagination configuration changed. page changed meaning; the metadata behind it did not.

The pagination cap is now validated at construction (#678, #680). This is not one of the 61, and it adds nothing to any per-SDK column above. Those count breaks the compiler will not catch: class A is silent, and class B needs a particular server response before it fails. This is neither. It fails at construction, deterministically, before any request is made, on a value you wrote literally, with a named configuration error that says what was wrong. You find out on the first line, every time, in development.

What newly raises depends on the SDK, because they did not start from the same place:

SDKnewly rejected at construction
TypeScript0, negatives, NaN, Infinity, fractional caps, Number.MAX_VALUE and anything else ≥ 2 ** 53
Kotlin0 and negatives (IllegalArgumentException via require)
Swift0 and negatives — precondition, which traps; BasecampConfig.init is public and non-throwing, so it has no way to return an error
Pythonnon-integers such as float("inf") and 2.5, and True
Go, Rubynothing — both already rejected a non-positive cap at v0.12.0

Python's 0 and negatives already raised at v0.12.0; only the type check is new. Go already panicked and Ruby already raised ArgumentError, so neither moves.

The one that will actually bite is maxPages: Infinity in TypeScript, and it is why #678 carries the breaking label. At v0.12.0 there was no validation at all, so writing Infinity to mean "no cap" ran — and did exactly what it said, following rel="next" without a bound. It now throws. If that was your idiom, pass a real number.

An absent cap is unchanged, and that includes an explicit null: both undefined and null fall through to the default 10,000, in the client factory and in a directly-constructed service alike. #680 fixed a disagreement between those two doors that would otherwise have shipped — the factory rejected null where the service defaulted it.

Coverage was corrected and re-scoped, not completed. Five of the six routes tracked as gaps turned out to be phantoms — already modelled at their flat spelling — and both dock-door creation and schedule-recurrence writes were deferred. Net coverage moved by one operation. See Coverage: corrected and re-scoped.

Before you upgrade — operator checklist

Four items here are invisible to a compiler and to a test suite that mocks the network. Two of them are production incidents if you skip them.

1. Operation allowlists and denylists will start denying — or start passing

The merge-safe composites changed the operation identity your hooks observe. A gating hook keyed on the old identity denies the call after upgrade; a denylist keyed on it stops blocking. Nothing in your build catches this.

Every SDK reports operation identity differently. Use your own row.

Go and Ruby expose a short verb:

SDKv0.12.0 emittedv0.13.0 emits
Go{Todolists, Update}{Todolists, Get} then {Todolists, Replace}
Go{Documents, Update}{Documents, Get} then {Documents, Replace}
Go{Schedules, UpdateEntry}{Schedules, GetEntry} then {Schedules, ReplaceEntry}
Ruby{todolists, update}{todolists, get} then {todolists, replace}
Ruby{documents, update}{documents, get} then {documents, replace}
Ruby{schedules, update_entry}{schedules, get_entry} then {schedules, replace_entry}

TypeScript, Python, Kotlin and Swift put the wire operation ID in the same field, so their strings differ:

v0.12.0 emittedv0.13.0 emits
UpdateTodolistOrGroupGetTodolistOrGroup then UpdateTodolistOrGroup
UpdateDocumentGetDocument then ReplaceDocument
UpdateScheduleEntryGetScheduleEntry then ReplaceScheduleEntry

Two traps in that second table. The todolist pair kept its names — the operation ID UpdateTodolistOrGroup did not change — so an allowlist containing it keeps working for the write and denies the new read, which fails the call just as completely. And UpdateDocument / UpdateScheduleEntry no longer exist anywhere: an allowlist entry for either is now dead text.

Three operation IDs were also removed outright and will never match again: TrashTodo, GetRecording, CreateForwardReply.

Cards move the opposite way, and an allowlist is not automatically safe. The cards fix is in this release: cards.update stops issuing its GetCard and collapses to a single UpdateCard. Nothing starts being denied — but if your allowlist names the write and deliberately omits the read, that omission used to reject cards.update at its first request and now does not. The same applies to a denylist on GetCard. Audit for gates that were stopping a write by way of the read it used to make; those stop holding, and no denial appears in your logs to tell you.

2. Request counts and rate-limit budget move

Each merge-safe update is now two HTTP requests. downloadURL's first hop retries up to three times (#563). Recount any per-call budget, request-count assertion, or single-shot mock.

3. Path-keyed infrastructure needs repointing

Eleven operations emit a different URL. Proxy and WAF allowlists, log and APM dashboards keyed on path, VCR/WebMock/MSW/nock/respx/URLProtocol handlers, and recorded cassettes all match on the old spelling and will not match the new one. Depending on the tool that is a passthrough, a wrong-fixture pass, or an unrelated-looking failure.

4. max_retries of 0 or 1 breaks token refresh (#571)

If you run a refreshable token provider with max_retries set to 0 or 1, an expired token on a read now raises an auth error instead of refreshing. Raise it to at least 2. The default of 3 is unaffected, and a static token provider is unaffected. Full detail in Retry and transport.

5. Clearing a card's due date is already broken in production

This one is not caused by upgrading. It is true of the version you are running right now, and it is the reason to upgrade rather than a hazard of doing so.

Every released SDK encodes "clear this card's due date" by omitting due_on from the update body. That worked because bc3 built its card update params as { due_on: nil }.merge(card_params), so an omitted key erased the date. bc3 changed that: on the JSON representation an omitted key is now left unchanged. The change deployed before any SDK release could match it.

So today, against production:

  • cards.update(...) asking to clear a due date is a silent no-op. The request succeeds, returns 200, and the due date is still there.
  • The same call has stopped being destructive in the other direction, which is the good half: a sparse update that never mentioned due_on used to erase it and no longer does.

v0.13.0 is the release that fixes it. cards.update now encodes a clear as "due_on": "", which bc3 blank-casts to nil — see Cards: the due-date fix for the SDK-side shape and what it costs you in hook events. There is nothing to configure. Until you are on this release, treat "clear a card due date" as unavailable and verify by reading the card back.


Breaks your compiler will not catch

Everything in this section survives a clean build. That is the only property all of it shares, and it is why the section exists: the rest of the guide is work your toolchain will find for you, and this is not.

Within it there are two classes, and they fail differently enough that mixing them would be misleading:

Class A — no signal at all (55). Running your existing code against a live Basecamp server, nothing tells you: it does not fail to compile, does not raise, does not fail to decode, and does not change the shape of what you get back. The call keeps working and does something different. These are the dangerous ones, because there is no moment at which you find out.

Class B — fails at runtime, on the wrong payload (6). Compiles, then panics or raises. You do get a signal; you get it late, from a stack trace, and only sometimes.

These six are classified for the payload that fails. That is a real limitation of the scheme and worth stating rather than hiding: class B is not a property of the call, it is a property of the call plus a response. The same method, against a response of the other shape, does not break at all — it behaves exactly as it did at v0.12.0. So "is this class B?" has no answer until you say which response you mean, and every entry below names its trigger.

That is also why the two classes need opposite test fixtures, and why "we tested it against realistic data" is not evidence:

  • Absent-field triggers (all four Go entries). A pointer that is nil because the server omitted the key. A fixture that populates every field never trips these — and against a fully-populated response they are not breaks.
  • Populated-field triggers (the Ruby entry and the Kotlin one). Ruby's is a value that is now a Time rather than a String, so the failure needs the field to be present. Kotlin's is narrower still: the field must be present and carry a JSON number or boolean where the model declares a string. A fixture that leaves either nil never trips them — and against an absent field the behaviour is unchanged, because both versions did the same thing there.

Class A has no dependency on a field's presence — but that is not the same as "every response", and the distinction matters when you go looking for one. Most class-A entries do fire on every call of the affected method: the URL corrections (#586), the page selector (#617), the merge-safe composites (#574, #601, #632), the cards composite collapsing the other way (#647), the empty-slice marshalling change (#560). Three groups do not, and their precondition is a property of the response or of your configuration, not of a field you can populate:

  • The error-message and validation entries (#541, #549) need an error status to reach the code at all — a 2xx never composes an error message — and the field-map half additionally needs the body to be of a particular shape (recognition is all-or-nothing; see the Python entry). A suite that only exercises happy paths sees none of it.
  • downloadURL's hop-1 retry (#563) changes nothing until a network error or one of {429, 502, 503, 504} actually occurs. Against a healthy server it is indistinguishable from v0.12.0.
  • Ruby's floored attempt cap (#656) is reachable only when you have set max_retries to 0 and the GET carries no operation ID. Every other configuration is bit-identical to v0.12.0 — see the Ruby entry before you go auditing.
SDKclass Aclass B
Go124
Swift100
TypeScript90
Python80
Ruby101
Kotlin61

How these are counted, so the number can be checked against a rule rather than an impression:

  • One entry per distinct change, per SDK, counted in the SDK where it bites. A change that is a compile error in one SDK and silent in another appears only under the second.
  • A change counts as class A if any ordinary call-site shape stays silent, even when another shape is compile-caught — annotated with which is which. Go's Documents().Update is the type case: silent for pkg/basecamp consumers, compile-caught only for direct pkg/generated importers.
  • Where one change has a second face — a marshalling difference, a reformatted string — that face is annotated in place as class-A residue and counted once, against its parent change, not separately.
  • Entries that raise only on a malformed or unusual server response are class B, not class A.

That rule-first stance is the contract for every number here: a count appears in this document only with a stated derivation rule.

Neither class is a property of your test suite. Several class-A breaks will fail loudly in a suite that pins request paths — a URL correction stops matching a WebMock/MSW/respx stub, and a strict double raises on the unregistered request. That is the good case, and it is called out where it applies. It is not a contradiction: the break is silent in production, and your mocks are the one thing that might catch it first. A suite that stubs loosely, or matches on method and host only, catches nothing.

Hits every SDK

1. page now selects one page instead of starting a walk (#617)

Applies to TypeScript, Python, Ruby, Kotlin and Swift. Go changed differently — see below.

At v0.12.0 in those five, seventeen already-paginated operations treated page as a starting offset: the SDK put page=N on the first request and then followed Link: rel="next" to the end of the collection. Now a positive page means one request and no link-following, and meta.truncated reports whether a further page existed.

Wrong behaviour you get: a job that read page: 3 and processed "everything from page 3 onward" now processes one page and reports success. If that job drives a sync, the sync silently stops covering most of the collection.

The seventeen: ListMyBookmarks, ListMyDrafts, GetBubbleUps, and the fourteen auto-paginated Everything* readers (completed / no-due-date / not-now / open / unassigned cards, checkins, comments, files, forwards, messages, completed / no-due-date / open / unassigned todos). GetMyNotifications also took page but never auto-paginated, so its meaning is unchanged.

Fix: drop page entirely to get the old walk (cap it with maxItems/max_items), or drive the page loop yourself and stop when meta.truncated is false. Absent, 0 and negative all still walk the collection.

Go is the exception

Do not apply the fix above to Go. At v0.12.0 a positive Page in Go already meant one request — the wrapper returned before followPagination whenever Page > 0. Dropping Page in Go does not restore old behaviour; it converts a bounded, single-request call into a full traversal of the collection.

What changed for Go is narrower, and splits in two:

  • Where the page number was already honouredBookmarks().List, Drafts().List, the Everything* readers — v0.12.0 sent page=N and returned that page. Nothing about the request changed. Meta.Truncated is now populated where it used to be left false.
  • Where the page number was silently ignored — the options structs whose v0.12.0 doc read "Page, if non-zero, disables pagination and returns only the first page. NOTE: The page number itself is not yet honored due to OpenAPI client limitations." Those built their params without Page at all, so they sent no page and returned page 1's rows under any positive Page. They now send page=N and return page N. Fourteen services carried that doc at v0.12.0: cards, checkins, comments, events, forwards, messages, people, projects, recordings, schedules, timeline, todolists, todos, vaults.

Gauges().List and ListNeedles are in neither group — they took no options and no page at all at v0.12.0, so gaining both is purely additive.

So Go's break here is wrong page returned, not walk collapsed. A Go job that passed Page: 3 and quietly processed page 1 for months now processes page 3 — which is what it always asked for, and a different set of rows than it has been handling. Audit for code that compensated for the old behaviour.

#561 brought page to the rest of the list surface (18 → 56 parameter structs). That half is purely additive.

2. Two operations emit a different URL under an unchanged signature (#586)

PUT  /{account}/todolists/{groupId}/position.json   →  /{account}/todolists/groups/{groupId}/position.json
GET  /{account}/inboxes/{inboxId}/forwards.json     →  /{account}/inboxes/{inboxId}/inbox_forwards.json

Same method name, same arguments, same return type, in all six SDKs. Both old paths 404'd against bc3, so no working call is being taken away — but a test double registered on the old path stops matching. Repoint mocks, cassettes, proxy allowlists and path-keyed dashboards.

The nine #619 bucket-scoping rewrites also change URLs, but they additionally change the call signature, so you will at least be looking at the code. These two give you nothing.

3. Error message text changed, on more error shapes than you would expect (#541, #549)

400 and 422 responses now fold field detail into the message: "color: is not a valid color", or "Invalid record (color: is not a valid color)" when the body also carries a top-level message. Fields sort lexicographically, a field's messages join with "; ", fields join with ", ". A bare unwrapped field map ({"content": ["can't be blank"]}, no errors wrapper) is recognised too, all-or-nothing by shape.

Separately, a top-level "message" key is now honoured as a general fallback at every status, not just validation — so not-found, forbidden, auth and generic API errors can carry different text than before.

Wrong behaviour you get: if (e.message === "…"), a regex over the message, or an error-grouping key derived from it silently stops matching. The branch it guarded stops running, and your error dashboard grows a new bucket.

Fix: branch on the error case or the HTTP status, and read the new structured field map — error.FieldErrors (Go), error.fieldErrors (TypeScript, Kotlin, Swift), e.field_errors (Python, Ruby). It is raw and untruncated; the message is capped at 500 characters.

TypeScript users: this is bigger for you than for the others. At v0.12.0 every error from a generated service call carried the HTTP status text, never the server's message. See the TypeScript section.

4. update on todolists, documents and schedule entries is a read-modify-write (#574, #601, #632)

Same method name, same request shape in most SDKs, same return type. What changed:

  • Two HTTP requests, not one.
  • Not atomic. A concurrent write inside the GET→PUT window is overwritten. Last write wins on the whole representation.
  • Omission no longer clears. Passing nothing for description/content used to erase it, because bc3 rebuilds the recordable from the permitted params it receives. Now it is preserved. To clear, pass "" explicitly, or use the new replace.
  • Hook operation identity changed — see the operator checklist.
  • Malformed read-backs are refused, not written through. A GET body that is not an object, or a writable field that is not the type the spec claims, aborts before the PUT with a statusless API error.

Go — 12 class A, 4 class B

Go carries every panic-shaped class-B break in the release; Ruby's and Kotlin's raise instead. All four Go entries come from #560/#615/#658's pointerization, and all four share one shape: Go auto-dereferences a pointer for a field selector and for a value-receiver method call, so the old code compiles untouched and panics only when the server omits that field.

Class B — panics at runtime, on the wrong payload

  1. Five optional timestamps became *time.Time (#615). hc.UpdatedAt.IsZero() still compiles and panics on nil. Fields: HillChart.UpdatedAt, Notification.ReadAt, Notification.UnreadAt, SearchResult.CreatedAt, SearchResult.UpdatedAt. Class-A residue: the two SearchResult tags also gained omitempty, so marshalling one now omits the key where it used to emit a fabricated 0001-01-01T00:00:00Z. That half never raises — check persisted output and downstream strict decoders.
  2. Five more wrapper timestamps became *time.Time (#658). #615's five were not the whole set — a second sweep found five that its omitempty-keyed check could not see. Fields: QuestionReminder.RemindAt, ClientApprovalResponse.CreatedAt, ClientApprovalResponse.UpdatedAt, TimelineEvent.CreatedAt, WebhookDelivery.CreatedAt. Identical failure shape to entry 1, so audit ten wrapper fields, not five. Class-A residue: all five gained omitempty, which none of them carried before, so marshalling now omits the key instead of emitting 0001-01-01T00:00:00Z.
  3. ~107 optional fields in pkg/generated with struct or named types became pointers (#560). a.Limits.CanUploadFiles and t.DueOn.String() both compile and both panic. Of 653 value→pointer flips, 527 scalars and 19 slices break at compile time; these ~107 do not.
  4. Question.Schedule.Hour and .Minute can now be nil (#560). The one flip in the release running guaranteed-non-nil → nil, so *q.Schedule.Hour that was unconditionally safe now panics. Class-A residue: WeekInstance, WeekInterval and MonthInterval moved the other way — nil → non-nil pointer to 0 — so a != nil presence test on those now fires where it did not, silently.

Class A — no signal at all

  1. Nested optional objects switched to pointer presence (#560). 34 guards across 17 files flipped from content-inference to != nil. A present-but-empty object that used to yield nil now yields a non-nil struct.
  2. A non-nil empty slice now reaches the wire (#560). Types: []string{} used to be a no-op; it now sends {"types":[]} and clears the list.
  3. CardColumns().Move always sends position, including 0 (#560).
  4. Page is a selector (#561, #617) — see cross-SDK #1, and the Go carve-out.
  5. Todolists().Update is read-modify-write (#574) — see cross-SDK #4.
  6. 400/422 from the raw Client/AccountClient escape hatch now report CodeValidation, not CodeAPI (#549).
  7. pkg/generated Parse*Response went lenient on 4xx/5xx (#541).
  8. Two URLs changed (#586) — see cross-SDK #2.
  9. Documents().Update is a read-modify-write emitting ReplaceDocument (#601). The method signature and UpdateDocumentRequest are both unchanged, so every pkg/basecamp call site compiles untouched and silently becomes GET+PUT with preserve-on-omission and two hook events. Only direct pkg/generated importers get a compile error, from the UpdateDocument* symbols disappearing.
  10. Validation Message text and hint composition changed (#541). parseErrorBody now decodes each member independently as json.RawMessage, so {"error": {}, "error_description": "…"} yields the hint where it previously yielded nothing. Typed service methods already returned CodeValidation for 400 and 422, so for most callers the text is the only thing that moved — and any string match on it is dead.
  11. Cards().Update dropped its preservation GetCard (#647). The signature and UpdateCardRequest are unchanged, so every call site compiles untouched; what moves is the request count, the hook sequence and the encoding of a clear. See Cards: the due-date fix.
  12. Schedules().CreateEntry stopped validating StartsAt/EndsAt as RFC3339 (#664). CreateScheduleEntryRequest's two fields were already string and still are, so nothing about the call site changes — but the local ErrUsage guard is gone and the value goes on the wire verbatim. A bare date now creates an all-day entry where v0.12.0 refused it before the request left, and a genuinely malformed value now reaches bc3 instead of failing locally with CodeUsage. Code that used that error as its input validation has no validation.

One more is partly compiler-visible: Error gaining FieldErrors mid-struct breaks unkeyed composite literals and nothing else. Schedules().UpdateEntry is not in this class — UpdateScheduleEntryRequest's fields became pointers, so any pkg/basecamp call site that set even one field fails to compile. It is in Compile errors.

Swift — 10 class A

  1. Error message recomposed at every status (#541). The v0.12.0 fallback was HTTPURLResponse.localizedString(forStatusCode:) — measured on Darwin: 400 → "bad request", 422 → "client error", 404 → "not found". Lowercase and locale-dependent. Any string match on error.message is dead.
  2. documents.update became merge-safe under an identical signature (#601). The generated UpdateDocumentRequest was renamed ReplaceDocumentRequest and a hand-written UpdateDocumentRequest with a character-identical public shape took the name.
  3. schedules.updateEntry became merge-safe under a superset signature (#632). The new init's labels are a strict superset with pre-existing order preserved, so v0.12.0 call sites compile untouched.
  4. Two URLs changed (#586)forwards.list emits /inbox_forwards.json, and todolistGroups.reposition emits /todolists/groups/{id}/position.json. A regex stub on todolists/\d+/position\.json cannot match the new path and fails open.
  5. todolists.update is a merge-safe composite (#574, #628). Mostly a compile error — but try await account.todolists.update(id: 1, req: .init(name: "x")) compiles unchanged, because leading-dot inference resolves .init against whichever type the parameter has and both expose init(description:name:). That idiomatic shape silently becomes two requests with preserve-on-omission semantics. Call sites naming UpdateTodolistOrGroupRequest explicitly do get a type error.
  6. page selects a page (#617). BookmarksService, DraftsService, EverythingService and MyNotificationsService all carried page at v0.12.0 and already appended ?page=. What they did not do was stop link-following.
  7. A custom Transport that throws BasecampError.network is now retried (#592). v0.12.0 failed any BasecampError from the transport on sight: one request, no onRetry, no backoff.
  8. A cancelled request now throws raw URLError(.cancelled) / CancellationError (#568). catch let error as BasecampError no longer matches; the error escapes to your next handler.
  9. downloadURL's first hop retries three times (#563).
  10. cards.update dropped its preservation GetCard (#647). The signature is byte-identical — DueDate.preserve still exists and still means "leave it alone", it just omits the key now instead of fetching and resending. One request, one hook event. See Cards: the due-date fix.

TypeScript — 9 class A

  1. Every error message from a generated service call was previously the HTTP status text (#541). At v0.12.0 BaseService.handleError discarded the body openapi-fetch had already parsed and re-read a spent Response, which throws, is swallowed, and falls back to statusText. So 403 {"error":"You are not allowed"} gave "Forbidden". On main the server's text reaches message at every status. Any e.message === "<statusText>" comparison is now dead code.
  2. Two operations emit different URLs under an unchanged signature (#586) — see cross-SDK #2. The nine #619 bucket-scoping rewrites also move URLs but are compile errors here (TS2345 on clientCorrespondences.list, not TS2554), so they are in Compile errors.
  3. Operation IDs seen by hooks were renamed, removed and doubled. OperationInfo.operation is a plain string, so a stale comparison compiles and never matches again — an audit hook that was gating writes silently stops gating them.
  4. page selects a page (#617) — see cross-SDK #1.
  5. client.downloadURL() now retries hop 1 (#563). Single-shot mocks misbehave; if you wrapped downloadURL in your own retry loop you now have nested retry.
  6. todolists.update() is merge-safe (#574). Nothing to change to compile; two requests per call and omission no longer clears. It additionally throws Errors.usage locally for { name: "" }, which v0.12.0 sent and let bc3 422 — not a happy path, so it does not disqualify the entry.
  7. documents.update() is merge-safe (#601). UpdateDocumentRequest keeps its exact shape, so call sites are untouched.
  8. schedules.updateEntry() is merge-safe with a four-field carve-out (#632). The request type was renamed, but the merge-safe type is a superset of the old field set, so an inline object literal — the common shape — compiles unchanged and changes semantics.
  9. cards.update() dropped its preservation GetCard (#647). UpdateCardRequest is unchanged and every call site compiles untouched. One request instead of two, one hook event instead of two, and dueOn: null now goes on the wire as "" rather than triggering a read-and-resend. See Cards: the due-date fix.

Python — 8 class A

  1. ValidationError text changed and field_errors is new (#541, #549). str(e) went from "Validation failed" to "color: is not a valid color", and a bare unwrapped field map now populates field_errors too. Recognition is all-or-nothing by shape: one member that is not a non-empty list of non-empty strings disqualifies the whole body, so never assume a 400/422 yields a field map.
  2. Two URLs changed (#586) — see cross-SDK #2.
  3. account.download_url's first hop retries and dropped its Accept header (#563). v0.12.0 sent one request with Accept: application/json and raised on a 503. Main sends no Accept at all and retries {429, 502, 503, 504} plus network errors. Cassettes matching on Accept stop matching.
  4. page selects a page (#617) — see cross-SDK #1. Measured with a mock transport that always returns a next link: v0.12.0 get_everything_open_todos(page=3) issued 10,000 requests (the max_pages cap) starting at ?page=3; main issues exactly one.
  5. todolists.update() is a merge-safe GET+PUT (#574). Signature identical.
  6. documents.update() is a merge-safe GET+PUT (#601).
  7. schedules.update_entry() is merge-safe (#632). Every v0.12.0 keyword still binds.
  8. cards.update() dropped its preservation get (#647). Keyword set identical. One request instead of two, one hook event instead of two, and due_on="" is now the clear encoding. See Cards: the due-date fix.

All three merge-safe composites keep byte-identical keyword sets, raise nothing on the happy path, and produce no type-checker complaint — Python has no compile step, so nothing anywhere warns you. The cards collapse is the same shape in reverse, and just as quiet.

Ruby — 10 class A, 1 class B

Class A — no signal at all

  1. page: selects a page (#617) — see cross-SDK #1. Measured against a 4-page stub: v0.12.0 issued three requests and returned three items for page: 2; main issues one and returns one.
  2. ValidationError#message changed and #field_errors is new (#541, #549). e.message == "Request failed" stops matching.
  3. Two URLs changed (#586) — see cross-SDK #2. Loud in a stubbed suite, because WebMock raises on an unregistered request; silent against live bc3.
  4. account.download_url's first hop retries and dropped its Accept header (#563). v0.12.0 called http.get_no_retry, which sent Accept: application/json and did not retry. Main calls get_download, which is request_with_retry(:get, url, retry_on: DOWNLOAD_RETRY_ON, accept: nil) — so no Accept header at all, and up to three attempts on {429, 502, 503, 504} plus network errors. Cassettes matching on Accept stop matching, and single-shot download stubs see more requests.
  5. List methods request eagerly and return ListEnumerator (#557). enum = account.projects.list is byte-identical source that now costs a request at call time. See the detail below — errors move, and hook pairs no longer match per-iteration.
  6. todolists.update is a merge-safe GET+PUT (#574).
  7. documents.update is a merge-safe GET+PUT (#601).
  8. schedules.update_entry is a merge-safe GET+PUT (#632). None of the three update keyword sets changed — only replace/replace_entry tightened — so every call site binds unchanged and behaves differently.
  9. cards.update dropped its preservation get (#647). Keyword set identical. One request instead of two, one hook event instead of two, and due_on: "" is now the clear encoding. See Cards: the due-date fix.
  10. max_retries: 0 now sends one request where it sent none (#656) — see the entry below. Narrow: only an ungoverned GET, only at max_retries 0.

Class B — raises at runtime, on the wrong payload

  1. Draft#scheduled_posting_at and MyNote#created_at/#updated_at decode to Time, not String (#560). .start_with? raises NoMethodError and Time.parse(…) raises TypeError — but only on a record where the field is populated, so a fixture that leaves it nil never trips either. #iso8601 gets the old string back. Class-A residue: bare interpolation raises nothing and silently changes format from "2026-01-02T03:04:05Z" to "2026-01-02 03:04:05 UTC", so anything writing that value into a log line, a cache key or an external payload changes what it emits with no error at all.

Kotlin — 6 class A, 1 class B

  1. documents.update quietly stopped erasing omitted fields (#601). UpdateDocumentBody was removed from the generator and re-declared by hand in the same package with a character-identical public shape, and documents.update(id, body): Document kept its exact signature. The call site compiles untouched and does something different.

  2. Two URLs changed (#586) — see cross-SDK #2.

  3. Error message composition changed at every status (#541, #549) — see cross-SDK #3. The same parser now backs account.downloadURL, so download failure messages moved too.

  4. page selects a page (#617) — see cross-SDK #1. Seventeen operations, no build-time signal.

  5. downloadURL retries hop 1 (#563). DOWNLOAD_RETRY_ON = {429, 502, 503, 504} plus network errors, gated on config.enableRetry (default true). A single-shot 503 mock now sees three requests; 500 is deliberately not retried.

  6. cards.update dropped its preservation get (#647). The signature is unchanged. One request instead of two, one hook event instead of two, and dueOn = "" is now the clear encoding. See Cards: the due-date fix.

Class B — raises at runtime, on the wrong payload

  1. The client decoder stopped coercing a wrong-typed scalar into a String (#660). No type, field or method signature moved — the only change is that the client-wide Json no longer sets isLenient. At v0.12.0 a response carrying "description": 42 or "title": false decoded to "42" / "false" for any String/String? member on any model; it now throws a raw kotlinx.serialization.SerializationException. Two properties worth planning around. The trigger is a field that is present and populated with a JSON number or boolean — absence and explicit null are unaffected, so a fixture that omits the field never trips it. And the throw happens in the response decode, so on a write the mutation has already landed: cards.update issues its PUT, the card changes, and then the decode raises. catch (e: BasecampException) does not see it — todolists.update/edit are the exception and wrap it as BasecampException.Api.

Kotlin's other two merge-safe composites are not in class A because the compiler does catch them: todolists.update takes a different body type and UpdateScheduleEntryBody no longer exists. Only documents.update survives the build, via the hand-written same-package shim, and that is class-A entry 1.


Route corrections

Operations that declared URLs bc3 does not serve. Read the removals carefully — one of them was serving.

#586 — two spellings corrected, no signature change

Covered above as class-A break #2. Both old paths were 404s.

#619 — nine operations gained a bucket scope

Five campfire chatbot operations, ListClientApprovals, ListClientCorrespondences, ListClientReplies and GetClientReply gained a required leading bucket (project) ID and a /buckets/{bucketId} path prefix.

All nine flat paths were verified against bc3's config/routes.rb, not against its documentation: the flat chats resource nests only lines and uploads (no integrations), and the flat client namespace is only: %i[show] and draws neither recordings nor replies. Every prior call was a 404, so nothing that worked stops working. GetClientApproval and GetClientCorrespondence are correctly flat and are untouched.

#619 — three operations removed

GetRecording and CreateForwardReply were 404s too:

  • GetRecording — bc3 draws resources :recordings, only: [], so the flat show does not exist. The bucket-scoped show is drawn, but app/views/api/recordings/ holds only partials, so it cannot render on the API host either. There is no correct path in any shape. See Known gaps.
  • CreateForwardReply — the flat create was never drawn (resources :inbox_forwards, only: %i[show]). The bucket-scoped create exists but is undocumented with no upstream coverage, so it was not substituted in. No SDK replacement.

TrashTodo is the exception, and the reassurance above does not cover it. DELETE /{account}/todos/{todoId} was drawn (resources :todos, only: %i[show edit update destroy]), returned 204, and mutated data. It is the one removal that takes away a call that worked.

What it did was not what its name said. TodosController#destroy writes destroy_status_param, which defaults to "archived"; bc3's own test asserts archived? after a bare DELETE. Every caller was archiving, not trashing. It was removed rather than renamed so that this decision cannot be skipped:

you wantcall
the behaviour you actually hadrecordings.archive(<todo id>)
what the old name promisedrecordings.trash(<todo id>)

The compiler catches the removal. Nothing catches the wrong choice — do not sed one into the other.


Merge-safe composites

Three update methods were sparse PUTs. bc3 rebuilds the recordable from the permitted params it receives, so a field you did not send was erased, with a 200 and no warning.

MethodWhat omission used to erase
todolists.update (#574)the list's description
documents.update (#601)the document's content; an omitted title read back as "Untitled"
schedules.updateEntry (#632)summary, start/end, description, and the all-day flag

Each now issues a GET, overlays the fields you addressed, and PUTs the full representation. The one-shot destructive PUT survives under an honest name — replace, replaceEntry — and edit blocks give read-modify-write in one call.

Schedule entries carry a carve-out set deliberately kept off the wire: participantIds, url (the join link), highlighted and notify reach the server only when the caller addresses them, because bc3 seeds them from the existing recordable on omission. Echoing a stale read into those fields would be wrong. Addressedness is key presence, not truthiness — url: "" is an explicit clear.

#597 extends the same guards to the two composites that already existed. Previously todos.update coalesced a false content to "" and wrote it back — erasing a field on a call that never mentioned it — and cards.update both dropped a falsey non-string due_on (which is how bc3 erases a due date) and forwarded a truthy non-string one.


Cards: the due-date fix

Cards move the opposite way to the three composites above: cards.update was a read-modify-write and becomes a single PUT. The reason is that the server bug it existed to defend against is gone — bc3 became presence-aware on the card JSON representation, so an omitted key is now left alone and there is nothing left for a composite to protect.

The consequence for anyone still on an older SDK is operator checklist item 5: the released encoding for "clear the due date" is omission, and omission no longer clears.

Status: shipped. #647 merged as 46b7f8225, in all six SDKs.

It touches no schema — not openapi.json, not spec/basecamp.smithy, not go/pkg/generated. An earlier draft of this guide said the fix would have to go Smithy-first because UpdateCardStepRequestContent.DueOn could not express ""; that generated field did change from types.Date to *types.Date across this release, but by #560's blanket pointerization, not by #647, and the card-step wrapper never used that struct — it hand-builds a map[string]any. The two changes rhyme and are unrelated.

The wire encoding of an explicit clear changes

clear a due date:   omit "due_on"        →   "due_on": ""

bc3 blank-casts "" to nil on the date attribute, so "" clears. null is not an option: it violates the body-compaction rule in SPEC §18, and five of the six SDKs strip nulls before the wire, so "" is the only clear encoding every SDK can express identically.

UpdateStepRequest.DueOn becomes *string

// leave the step's due date alone
ac.CardSteps().Update(ctx, stepID, &basecamp.UpdateStepRequest{Title: "Draft"})

// clear it
ac.CardSteps().Update(ctx, stepID, &basecamp.UpdateStepRequest{DueOn: basecamp.Ptr("")})

// set it
ac.CardSteps().Update(ctx, stepID, &basecamp.UpdateStepRequest{DueOn: basecamp.Ptr("2026-08-14")})

Presence is != nil, matching UpdateCardRequest. Title changes with it: an empty Title now leaves the title unchanged rather than being sent, because bc3 made title optional on update in the same change.

The hook and request sequence collapses

Cards().Update no longer issues a GetCard first. In Go it is now literally return s.UpdateVerbatim(ctx, cardID, req).

The v0.12.0 read was conditional, which narrows who is affected. All six SDKs took the GET only when the caller left due_on unaddressed — Go's if req.DueOn == nil, Python's current = self.get(...) if due_on is None, Kotlin's dueOn == null ->, and the equivalents in Ruby, TypeScript and Swift. A call that named dueOn explicitly was already a single PUT and is unchanged. The table below is therefore the unaddressed-due_on path:

v0.12.0 (due_on unaddressed)v0.13.0
wire operationsGetCard then UpdateCardUpdateCard
Go OperationInfo{Cards, Get} then {Cards, UpdateVerbatim}{Cards, UpdateVerbatim}
requests per call21
lost-update windowyes — a concurrent due-date change between GET and PUT was overwrittennone

This is the inverse of the {Todolists, Update} split in operator checklist item 1, so do not reason about it by analogy. There the added read could be denied; here the removed read means a gate that used to fire no longer does. Audit both lists — an allowlist is not safe just because nothing new appears on it.

  • An allowlist can silently open. Nothing starts being denied, which is the part that misleads. But if your allowlist names UpdateCard / {Cards, UpdateVerbatim} and deliberately omits GetCard / {Cards, Get}, then cards.update used to be rejected at its read and never reached the write. Collapsed to a single call, it is permitted end to end. A gate you were relying on stops holding, and no denial appears in your logs to tell you.
  • A denylist can silently open the same way. If you blocked {Cards, Get} / GetCard to stop reads, that denial used to take cards.update down with it. It no longer does.
  • Audit trails lose a record. Anything reconciling reads against writes, or billing per operation, sees one event where it saw two.

Those first two are the same hole seen from two policy shapes, which is why reading only one is dangerous: in both, the thing actually stopping the write was the read, expressed once as an omission from an allowlist and once as an entry on a denylist. Remove the read and both stop working, for identical reasons.

The general rule, which is easy to get backwards: removing an operation from a composite cannot cause a denial, but it can remove a denial you were depending on. Gate on the write you actually mean to stop, not on a read that happened to accompany it.

A defended defect class leaves the Cards surface

Removing the preservation GET also removes what that GET was validated against. Three errorRaised conformance kill cases go with it:

update-kill: an array due_on is refused before the replacement PUT
update-kill: an empty-object due_on is refused, not coerced or dropped
update-kill: a date-shaped array due_on is refused where the format check is blind

Those pinned the behaviour that a malformed due_on read back from the server was refused rather than coerced or forwarded into the write. With no read, there is no read-back to validate, so the guarantee is not weakened — it stops being reachable on this surface. cards_write.json goes from 8 cases to 5, and its errorRaised count from 3 to 0.

The class itself is not retired. It stays pinned on Todos, which still does a real read-modify-write: todos_write.json carries 3 errorRaised cases — the array and empty-object kills it already had, plus a bare-scalar kill added by #660. If you were relying on Cards to be the canary for malformed-read-back handling, it is not one any more — Todos is, and it now covers one shape more than Cards ever did.


Retry and transport

  • #571 — the 401 refresh replay now counts against the attempt budget, and the budget is checked before refresh() is invoked. Consumers running a refreshable token provider with max_retries of 0 or 1 now get an auth error where the token used to refresh silently. Raise it to at least 2; the default of 3 is unaffected. Reads only — mutations keep the uncounted replay. Even at ≥2 the replay spends an attempt, so a token rotation consumes a retry you may have been relying on for a following 429 or 503.
  • #563 — the authenticated download hop retries under a declared set (429, 502, 503, 504 plus network errors; never 500) in every SDK. Downloads that used to fail fast now recover, and single-shot download mocks now see up to three requests. Python and Ruby additionally stopped sending Accept: application/json on hop 1 — accept=None in python/src/basecamp/_http.py, accept: nil in ruby/lib/basecamp/http.rb — so a VCR cassette or WebMock stub that matches on Accept stops matching. The other four never sent it on that hop, at v0.12.0 or now, so nothing moved there.
  • #592 — the backoff formula gained a 30 s ceiling in all six SDKs. Swift additionally retries a custom Transport's own BasecampError.network instead of failing on sight.
  • #568 — Swift classifies cancellation as terminal and rethrows it raw, so a cancelled request no longer burns the retry budget and no longer arrives as a BasecampError.
  • #557 — Ruby's list enumerators carry pagination metadata and accept max_items. Page 1 is now fetched inside the call rather than on first iteration: errors surface at construction, and meta.total_count is available before you iterate.

Go

Go has the most invasive changes in this release. Sixteen survive a clean build: twelve give no signal at all, and four compile and then panic — see Go — 12 class A, 4 class B for the split.

The scale, so you can size the work before starting: pkg/basecamp's exported surface now carries 300 pointer-typed fields*string$ \times 81, $*bool$ \times 23, $*time.Time ×16, 14 pointer-to-slice, and the rest struct pointers. Derive it yourself with:

rg -N '^\s{1,2}[A-Z]\w*\s+\*[\w.\[\]]+\s' go/pkg/basecamp/*.go \
  | rg -v '_test\.go' | wc -l

Both halves of that round trip have an exported helper as of #643 — you do not need to hand-roll one:

basecamp.Ptr(v)   // func Ptr[T any](v T) *T   — set an optional field
basecamp.Deref(p) // func Deref[T any](p *T) T — read one, zero value when nil

Ptr never collapses false or "" to nil: sending an explicit zero is the whole reason these fields are pointers. T is inferred from the argument, so a field whose type is not an untyped literal's default needs the conversion written out — basecamp.Ptr(int32(5)) for an *int32 field, not basecamp.Ptr(5).

Deref is total, which makes it the wrong tool where absence carries meaning: collapsing "the server omitted this" into "" is only safe when your code cannot tell the two apart. Compare against nil where it can. See Optional Fields in the Go README.

Silent

Five optional timestamps became *time.Time (#615)

// v0.12.0 — all five were value time.Time
if !hc.UpdatedAt.IsZero() { fmt.Println(hc.UpdatedAt.Format(time.RFC3339)) }
for _, n := range notifications {
    if n.ReadAt.IsZero() { unread = append(unread, n) }
}
// main — the same lines COMPILE and panic on a nil pointer
if hc.UpdatedAt != nil && !hc.UpdatedAt.IsZero() { … }
for _, n := range notifications {
    if n.ReadAt == nil { unread = append(unread, n) }
}

Go inserts the dereference for a value-receiver method call on a pointer, so hc.UpdatedAt.IsZero() is rewritten to (*hc.UpdatedAt).IsZero() and builds clean. Assignments (var t time.Time = n.ReadAt) and arguments typed time.Time do fail to compile; only selector-and-method access is silent.

Grep the five field names and audit every .IsZero(), .Format(, .Before(, .After(, .Sub(, .Unix(), .Year(). To keep the old zero-value semantics verbatim:

t := basecamp.Deref(n.ReadAt)   // time.Time{} when the field is absent

That is exactly the old behaviour, because the old value field was the zero time when absent. Only reach for it where absence and a genuine zero are interchangeable to your code — if n.ReadAt == nil is the honest test when they are not.

Two consequences past the panic: absence used to read as 0001-01-01T00:00:00Z and now reads as nil, and json.Marshal of a SearchResult now omits created_at/updated_at when absent. Check persisted serialization and downstream strict decoders.

Five more wrapper timestamps became *time.Time (#658)

#615's five were not the whole set, and the check it shipped could not tell you so: TestNoValueTypedOptionalTimestamps keyed on the ,omitempty tag, and these five did not carry one. A second sweep pairs each wrapper field against its generated counterpart by struct name and json key, and found five more:

FileStructField
go/pkg/basecamp/checkins.goQuestionReminderRemindAt
go/pkg/basecamp/client_approvals.goClientApprovalResponseCreatedAt
go/pkg/basecamp/client_approvals.goClientApprovalResponseUpdatedAt
go/pkg/basecamp/timeline.goTimelineEventCreatedAt
go/pkg/basecamp/webhooks.goWebhookDeliveryCreatedAt

The failure is identical to #615's — ev.CreatedAt.Format(time.RFC3339) compiles, and panics when the server omits created_at. So the audit is ten fields, not five. Watch the near-miss siblings, which did not change and are easy to sed by accident: Webhook.CreatedAt/UpdatedAt, QuestionAnswer.CreatedAt/UpdatedAt, and ClientApproval.CreatedAt/ UpdatedAt — note that the last pair is on ClientApproval, while the pair that did move is on ClientApprovalResponse.

All five also gained ,omitempty, which none of them carried before, so json.Marshal now drops the key instead of emitting 0001-01-01T00:00:00Z.

pkg/generated: ~107 struct- and time-typed optional fields became pointers (#560)

// both lines still COMPILE on main, and BOTH panic when the field is nil
_ = a.Limits.CanUploadFiles     // Account.Limits is now *AccountLimits
fmt.Println(t.DueOn.String())   // Todo.DueOn is now *types.Date

The method call is no safer than the field selector. types.Date.String has a value receiver (func (d Date) String() string, go/pkg/types/date.go), so Go rewrites t.DueOn.String() to (*t.DueOn).String() and the dereference of a nil pointer panics before String is entered. The same holds for every value-receiver method on these types — IsZero, Before, After, Weekday on types.Date; Format, Sub, Unix, Year on time.Time. Only a pointer-receiver method would survive a nil receiver, and it would then have to handle nil in its own body; none of these do. Audit the method calls with the same care as the field selectors.

Of 653 value→pointer flips in pkg/generated, 527 scalars and 19 slices break at compile time. The other ~107 have a named or struct type with fields or methods, so Go auto-dereferences for a field selector and for a value-receiver method call: types.Date (24), time.Time (16), Person (15), RecordingParent (7), RecordingBucket (7), types.FlexInt (2), types.FlexibleTime (2), TodoBucket (2), QuestionSchedule (2), plus ~30 singletons (EventDetails, MessageType, Project, Recording, ClientCompany, CardColumnOnHold, TimelineEventData, WebhookCopy, …).

This only affects you if you import github.com/basecamp/basecamp-sdk/go/pkg/generated directly — no exported pkg/basecamp function or field mentions a generated type.

Nested optional objects switched to pointer presence (#560)

// v0.12.0 — presence was inferred from CONTENT
if ge.Details.AddedPersonIds != nil || ge.Details.RemovedPersonIds != nil { … }
if gf.Parent.Id != 0 || gf.Parent.Title != "" { … }
if !gc.CompletedAt.IsZero() { … }
// main — presence IS the pointer
if ge.Details != nil { … }
if gf.Parent != nil { … }
if gc.CompletedAt != nil { … }

34 guards across 17 files: client_approvals (4); todolist_groups, search, recordings, everything, cards (3 each); webhooks, timeline, reports, boosts (2 each); vaults, tools, timesheet, templates, projects, people, messages (1 each).

The flip always runs the same direction — a present-but-empty or present-but-zero object that used to yield nil now yields a non-nil struct. The canonical fixture emits "details": {} for events with no membership change. Sharpest: Card.CompletedAt and CardStep.CompletedAt, where a present zero timestamp now reads as "completed". So stop treating a non-nil nested pointer as "this thing happened":

if e.Details != nil && (len(e.Details.AddedPersonIDs) > 0 || len(e.Details.RemovedPersonIDs) > 0) {
    log.Printf("membership changed on %d", e.RecordingID)
}

Audit every x.Parent != nil, x.Bucket != nil, x.Creator != nil, x.Category != nil, x.CompletedAt != nil, x.DueOn != nil, x.Schedule != nil.

Question.Schedule.Hour and .Minute can now be nil (#560)

// v0.12.0: Hour/Minute were ALWAYS non-nil whenever Schedule was set
if q.Schedule != nil {
    fmt.Printf("%02d:%02d\n", *q.Schedule.Hour, *q.Schedule.Minute)  // always safe
}

// main: the generated nil is carried through — this panics
if q.Schedule != nil && q.Schedule.Hour != nil && q.Schedule.Minute != nil { … }

The only flip in the release running guaranteed-non-nil → nil. In the same hunk, WeekInstance, WeekInterval and MonthInterval moved the other way: they used to be nil when the server sent 0 and are now a non-nil pointer to 0, so a != nil presence test on those now fires where it did not.

A non-nil empty slice now reaches the wire (#560)

// v0.12.0 guards were len()-based
ac.Webhooks().Update(ctx, id, &basecamp.UpdateWebhookRequest{Types: []string{}})
// → PUT with no "types" key: the webhook's event list was left alone

// main guards are nil-based and the generated field is *[]T
ac.Webhooks().Update(ctx, id, &basecamp.UpdateWebhookRequest{Types: []string{}})
// → PUT {"types":[]}: the webhook's event list is CLEARED

nil now means "leave it alone"; []T{} means "set it to empty". If you build a slice with make([]T, 0) and append conditionally, a zero-match filter now clears the server-side list. Affected: Webhooks().Update (Types), Gauges().CreateNeedle (Subscriptions), Todos().Create/CreateInTodoset (AssigneeIDs, CompletionSubscriberIDs), CardSteps().Create (AssigneeIDs), Schedules().CreateEntry (ParticipantIDs), People().UpdateProjectAccess (Grant, Revoke, Create), QuestionSchedule.Days. Todos().Update/Replace already used the nil guard.

CardColumns().Move always sends position (#560)

MoveColumnRequest.Position lost omitempty, and the value is range-checked then sent unconditionally: POST {"source_id":a,"target_id":b,"position":0} where the key used to be absent. bc3 does params[:position].to_i so the server outcome is unchanged, but body-exact stubs and any payload you marshal yourself both change. A Position above 2147483647 is now ErrUsage("position must be between 0 and 2147483647") instead of a silent wrap to a negative int32.

Page is a page selector (#561, #617)

// v0.12.0 doc: "Page, if non-zero, disables pagination and returns only the
// first page. NOTE: the page number itself is not yet honored."
res, _ := ac.Todos().List(ctx, todolistID, &basecamp.TodoListOptions{Page: 3})
// → GET .../todos.json — page 1's rows, Meta.Truncated always false

// main
res, _ := ac.Todos().List(ctx, todolistID, &basecamp.TodoListOptions{Page: 3})
// → GET .../todos.json?page=3 — page 3's rows, Meta.Truncated populated

If you passed Page purely to mean "one request, don't auto-paginate", you now get page N. A positive Limit alongside a positive Page trims that page. Page > 2147483647 is now ErrUsage("page is out of range").

18 → 56 params structs carry Page; 38 wrapper call sites now pass one.

Todolists().Update is now read-modify-write (#574)

Signature unchanged; the data-loss bug is fixed for free. Three things move: two round-trips per call; non-atomic last-write-wins; and hooks now observe {Todolists, Get} then {Todolists, Replace}no {Todolists, Update} is emitted anywhere on main.

ac.Todolists().Update(ctx, id, &basecamp.UpdateTodolistRequest{Name: "Q3"})  // merge-safe
ac.Todolists().Edit(ctx, id, func(f *basecamp.TodolistFields) error { f.Description = ""; return nil })
ac.Todolists().Replace(ctx, id, &basecamp.ReplaceTodolistRequest{Name: "Q3"}) // verbatim, clears description

Update cannot clear a field — an empty string still reads as "unaddressed". Use Edit or Replace.

Raw escape-hatch 400/422 now report CodeValidation (#549)

If you already handle errors from the raw Client/AccountClient Get/Post/Put/Delete methods, the code moved:

if apiErr, ok := err.(*basecamp.Error); ok {
    switch apiErr.Code {
    case basecamp.CodeAPI:        // a 422 used to land here
    case basecamp.CodeValidation: // it lands here now, with FieldErrors populated
    }
}

Only those four changed; typed service methods already returned CodeValidation for both statuses. Match on apiErr.HTTPStatus if you need the old grouping.

This entry documents a change to an existing surface. It is not a suggestion to reach for the raw client — see no raw-wire migrations.

pkg/generated Parse*Response went lenient on 4xx/5xx (#541)

if err := json.Unmarshal(...); err != nil { return nil, err } became if err == nil { response.JSONxxx = &dest } across all 1058 status arms. A nil resp.JSONxxx no longer means "the status did not occur" — it can also mean "the body did not decode". Check resp.HTTPResponse.StatusCode and resp.Body. For pkg/basecamp users, if apiErr, ok := err.(*basecamp.Error); ok { … } else { /* parse error */ } now takes the first branch for malformed error bodies.

Two URLs changed (#586)

RepositionTodolistGroup and ListForwards. Go call sites are byte-identical.

Cards().Update dropped its preservation GetCard (#647)

UpdateCardRequest and the method signature are unchanged, so nothing in your build moves. Update is now literally return s.UpdateVerbatim(ctx, cardID, req) — one request where a call leaving DueOn nil used to make two, one OperationInfo where there were two, and DueOn: basecamp.Ptr("") as the clear encoding. Full detail, including what it does to allowlists and denylists, in Cards: the due-date fix.

Schedules().CreateEntry no longer validates the timestamps (#664)

req := &basecamp.CreateScheduleEntryRequest{Summary: "Offsite",
    StartsAt: "2026-06-01", EndsAt: "2026-06-01"}

// v0.12.0 — never reached the wire
_, err := ac.Schedules().CreateEntry(ctx, scheduleID, req)
// err.Code == CodeUsage: "schedule entry starts_at must be in RFC3339 format …"

// main — sent verbatim, creates an all-day entry
_, err := ac.Schedules().CreateEntry(ctx, scheduleID, req)

CreateScheduleEntryRequest.StartsAt and .EndsAt were string at v0.12.0 and still are; only the set of values they accept widens. bc3 takes a bare date for an all-day entry and a timestamp otherwise, and the client-side time.Parse(time.RFC3339, …) guard rejected the first — so the fix is unambiguously in the right direction. What is silent is the other half: a genuinely malformed value now reaches bc3 instead of failing locally with CodeUsage, and code that leaned on that error as its own input validation has none. The ""-is-required guards survive.

Compile errors

Nine operations gained a leading bucketID (#619)

// before                                    // after
ac.Campfires().ListChatbots(ctx, cid, nil)   ac.Campfires().ListChatbots(ctx, bucketID, cid, nil)
ac.Campfires().GetChatbot(ctx, cid, bid)     ac.Campfires().GetChatbot(ctx, bucketID, cid, bid)
ac.Campfires().CreateChatbot(ctx, cid, req)  ac.Campfires().CreateChatbot(ctx, bucketID, cid, req)
ac.Campfires().UpdateChatbot(ctx, cid, b, r) ac.Campfires().UpdateChatbot(ctx, bucketID, cid, b, r)
ac.Campfires().DeleteChatbot(ctx, cid, bid)  ac.Campfires().DeleteChatbot(ctx, bucketID, cid, bid)
ac.ClientApprovals().List(ctx, nil)          ac.ClientApprovals().List(ctx, bucketID, nil)
ac.ClientCorrespondences().List(ctx, nil)    ac.ClientCorrespondences().List(ctx, bucketID, nil)
ac.ClientReplies().List(ctx, rid, nil)       ac.ClientReplies().List(ctx, bucketID, rid, nil)
ac.ClientReplies().Get(ctx, rid, replyID)    ac.ClientReplies().Get(ctx, bucketID, rid, replyID)

The bucket ID is project.ID / Bucket.ID on any recording you already hold. GetClientApproval and GetClientCorrespondence are correctly flat and untouched. pkg/generated users: the same nine gained the parameter across 55 signature sites.

Todos().Trash removed (#619) — and it never trashed

err := ac.Todos().Trash(ctx, todoID)        // gone
err := ac.Recordings().Archive(ctx, todoID) // what you were ACTUALLY getting
err := ac.Recordings().Trash(ctx, todoID)   // what the name promised

See Route corrections. The compiler catches the removal; nothing catches the wrong choice.

Recordings().Get removed (#619)

Use Recordings().List(ctx, recordingType, opts) and filter, or the type-specific service. See Known gaps.

Forwards().CreateReply and CreateForwardReplyRequest removed (#619)

There is no supported replacement — see Known gaps. Reads are unaffected: Forwards().ListReplies and Forwards().GetReply remain.

UpdateScheduleEntryRequest fields became pointers (#632)

_, err := ac.Schedules().UpdateEntry(ctx, entryID, &basecamp.UpdateScheduleEntryRequest{
    Summary:        basecamp.Ptr("Standup"),
    StartsAt:       basecamp.Ptr("2026-01-01T09:00:00Z"),
    EndsAt:         basecamp.Ptr("2026-01-01T09:15:00Z"),
    ParticipantIDs: basecamp.Ptr([]int64{7, 9}),
    Notify:         basecamp.Ptr(true),
})

The pointers are load-bearing: nil means "leave the fetched value alone", a pointer to the zero value means "set it to empty" — Description: basecamp.Ptr("") clears it. Two new fields: URL (the join link) and Highlighted. AllDay was already *bool. StartsAt/EndsAt are no longer RFC3339-validated client-side — a malformed timestamp now reaches the server instead of failing locally, so bc3's bare-date all-day rendering round-trips.

The sharpest one to get wrong is ParticipantIDs *[]int64, where nil and empty are different instructions rather than degrees of the same one:

ParticipantIDs: nil,                        // leave the participants alone
ParticipantIDs: basecamp.Ptr([]int64{}),    // remove EVERY participant
ParticipantIDs: basecamp.Ptr([]int64{7,9}), // set the participants to 7 and 9

A []int64 you build by filtering is basecamp.Ptr(ids) either way — so a filter that matches nothing clears the entry's participant list instead of leaving it untouched. Guard on len(ids) > 0 if you meant "leave it alone".

Gauges().List and ListNeedles gained options and a result struct (#617)

res, err := ac.Gauges().List(ctx, nil)                    // *GaugeListResult
nres, err := ac.Gauges().ListNeedles(ctx, projectID, nil) // *GaugeNeedleListResult
fmt.Println(len(res.Gauges), res.Meta.TotalCount, res.Meta.Truncated)

nil opts reproduces v0.12.0 behaviour exactly (uncapped Link-header pagination).

TodolistGroups().Update removed in favour of Replace (#574)

Do not blind-rename. ReplaceTodolistGroupRequest.Description has omitempty, and bc3 rebuilds the recordable from the permitted params — a Replace that omits Description erases the group's description. For merge-safe behaviour, route group writes through the todolists endpoint, which is the same PUT /{accountId}/todolists/{id}:

ac.Todolists().Update(ctx, groupID, &basecamp.UpdateTodolistRequest{Name: "Design"})

There is deliberately no TodolistGroups().Update or .Edit.

UpdateGaugeNeedleRequest.Description became *string (#560)

Tri-state: nil leaves it untouched, basecamp.Ptr("") clears it. At v0.12.0 an empty string was indistinguishable from unset and could not clear.

UpdateStepRequest.DueOn became *string (#647)

// leave the step's due date alone
ac.CardSteps().Update(ctx, stepID, &basecamp.UpdateStepRequest{Title: "Draft"})

// clear it
ac.CardSteps().Update(ctx, stepID, &basecamp.UpdateStepRequest{DueOn: basecamp.Ptr("")})

// set it
ac.CardSteps().Update(ctx, stepID, &basecamp.UpdateStepRequest{DueOn: basecamp.Ptr("2026-08-14")})

Presence is != nil, matching UpdateCardRequest, whose DueOn was already *string at v0.12.0 and did not move. Title changes with it: an empty Title now leaves the title unchanged rather than being sent, because bc3 made title optional on update in the same change. This is the one part of #647 the compiler finds for you; the rest is class A.

UpcomingSchedule returns reduced types, and Assignable is gone (#648)

GetUpcomingSchedule declared the full ScheduleEntry schema while bc3 renders it through a reduced calendar partial, so the SDK promised fields the endpoint never sends and its converters zero-filled them silently. The response type is now a set of aliases onto purpose-built shapes:

type (
    UpcomingScheduleResponse     = generated.GetUpcomingScheduleResponseContent
    UpcomingScheduleEntry        = generated.UpcomingScheduleEntry
    UpcomingAssignable           = generated.UpcomingAssignable
    UpcomingScheduleBucket       = generated.UpcomingScheduleBucket
    UpcomingSchedulePerson       = generated.UpcomingSchedulePerson
    UpcomingAssignableParent     = generated.UpcomingAssignableParent
    UpcomingAssignableCompletion = generated.UpcomingAssignableCompletion
)

ReportsService.UpcomingSchedule(ctx, startDate, endDate) keeps its signature; everything else about the result moves, and every move is a build failure:

  • basecamp.Assignable and generated.Assignable are deleted.
  • Assignable.Title is now UpcomingAssignable.Content. bc3 has always emitted content and never title, so the field you were reading was permanently the zero value. This is the correction, not a regression.
  • UpcomingScheduleResponse.RecurringOccurrencesRecurringScheduleEntryOccurrences.
  • Because the aliases publish generated names, the initialisms flip: IDId, URLUrl, AppURLAppUrl.
  • DueOn/StartsOn go string*types.Date; Bucket and Parent become the narrowed value types UpcomingScheduleBucket{Id, Name} and UpcomingAssignableParent{Id, Title}; Assignees becomes []UpcomingSchedulePerson. UpcomingScheduleEntry drops fourteen members the partial never rendered and gains Recurring.
  • StartsAt/EndsAt on the entry are types.FlexibleTime, which is what basecamp.ScheduleEntry already used — not a change for readers.

Also new and loud: an empty startDate or endDate is now ErrUsage("window_starts_on is required") rather than a bc3 400.

pkg/generated only

  • 546 optional scalar and slice fields became pointers (#560), enforced by scripts/check-go-optional-pointers in make check. Two silent holes remain even for scalars: fmt.Println(a.OwnerName) compiles and prints a pointer address, and %v/%s on a *string yields the address.
  • 22 read operations gained a params *XxxParams argument (#561) — 128 signature sites. Pass nil to keep the old wire behaviour. The argument goes last among the fixed parameters, before the variadic reqEditors. Affected: GetAnswersByPerson, GetPersonProgress, GetProgressReport, GetProjectTimeline, GetQuestionReminders, ListAnswers, ListCampfires, ListCards, ListClientReplies, ListComments, ListDocuments, ListEventBoosts, ListEvents, ListForwardReplies, ListGaugeNeedles, ListPeople, ListProjectPeople, ListQuestions, ListRecordingBoosts, ListTodolistGroups, ListUploads, ListVaults.
  • ClientInterface and ClientWithResponsesInterface each lost 8 methods and gained 12. Any hand-written mock asserting var _ generated.ClientInterface = (*fake)(nil) fails until you add CreateFolder, CreateFolderWithBody, DeleteFolder, DestroyTimesheetEntry, GetFolder, ListFolders, ReplaceDocument, ReplaceDocumentWithBody, ReplaceScheduleEntry, ReplaceScheduleEntryWithBody, UpdateFolder, UpdateFolderWithBody. Consider embedding the interface.
  • UpdateMyNoteResponse.JSON422 and UpdateMyPreferencesResponse.JSON422 changed type to *FieldValidationErrorResponseContent (#549). These are the only type-changed fields that are not a plain pointer flip, so a mechanical migration misses them.
  • CreateScheduleEntryRequestContent.StartsAt and .EndsAt went time.Timestring (#664), and so did CreateScheduleEntryJSONRequestBody, its alias. A time.Time in one of those literals no longer compiles; pass the string you actually want on the wire. The sibling ReplaceScheduleEntryRequestContent carries string too, but it is a new type as of #632 — there is nothing to migrate there from v0.12.0. The public basecamp.CreateScheduleEntryRequest is untouched; see the silent half.
  • Two of the eight new Todolist members are required, and they are typed asymmetrically (#628, #637). Color is *string with the json tag "color" and no omitempty, because the field is required but nullable — so marshalling a Todolist always emits the key, "color": null included. CommentsAppUrl is a plain string, not a pointer, because it is required and never null on the wire; it is the one field in this release that does not follow #560's blanket pointerization, and dereferencing it does not compile. Neither member existed at v0.12.0, so for a Go consumer this is additive — it only changes what json.Marshal emits, which the eight-new-fields note above already covers.
  • The TodolistOrGroup union and generated.TodolistGroup are gone (#628). resp.JSON200 is a Todolist directly. In pkg/basecamp, TodolistGroup is now a true alias: type TodolistGroup = Todolist. All 24 old fields survive with byte-identical types and json tags; the type gains 8 (Description, DescriptionAttachments, GroupsURL, GroupPositionURL, BoostsCount, BoostsURL, Color, CommentsAppURL). Two edges break: a type switch carrying both case Todolist: and case TodolistGroup: is now a duplicate case, and json.Marshal of a TodolistGroup emits up to 8 more keys.

Behavioural

  • Documents().Update is read-modify-write; the wire operation is ReplaceDocument (#601). Hooks see {Documents, Get} then {Documents, Replace}. UpdateDocumentRequest is unchanged. pkg/generated users lose the UpdateDocument* symbols outright. New failure mode: Update/Edit abort with a CodeAPI error if the GET returns a blank title.
  • Schedules().UpdateEntry is read-modify-write; the wire operation is ReplaceScheduleEntry (#632). Hooks see {Schedules, GetEntry} then {Schedules, ReplaceEntry}. ScheduleEntryFields splits its state: Summary, StartsAt, EndsAt, Description, AllDay are resent every time; URL, Highlighted, ParticipantIDs, Notify are behind setters and only reach the wire if you call the setter, because bc3 preserves those on omission. Recurring entries surface as a decode error on the GET.
  • Error gained FieldErrors mid-struct (#541). An unkeyed composite literal basecamp.Error{code, msg, hint, status, retryable, reqID, cause} no longer compiles. Validation Message text changed. parseErrorBody now decodes each member independently as json.RawMessage, so {"error": {}, "error_description": "…"} yields the hint where it previously yielded nothing.

Swift

Swift carries ten breaks with no signal at all — second only to Go's twelve — because three of its request types were replaced by same-named hand-written ones, and two of its retry and error policies changed under unchanged signatures.

One soft edge to know before you start: optional → non-optional breaks if let, guard let and ?. hard, but x ?? default only warns. A consumer whose entire usage is ?? sees nothing.

Silent

All ten are in class A. Two deserve code here, and a third — todolists.update's leading-dot .init shape — is under Compile errors because every other shape of that call does fail to build.

A cancelled request now throws raw (#568)

do { _ = try await account.projects.list() }
catch let error as BasecampError { report(error) }   // no longer matches
catch is CancellationError { … }
catch let error as URLError where error.code == .cancelled { … }

The classifier looks through BasecampError.network(cause:), bounded to 8 links, so a custom Transport that wraps URLError(.cancelled) in .network is treated as cancellation too — and that wrapped shape, which used to be retried, is now terminal. The upside: cancellation no longer burns the retry budget on a request the caller abandoned.

A custom Transport's own .network error is now retried (#592)

v0.12.0 failed any BasecampError out of the transport on sight. Main routes .network to the retry branch: up to maxAttempts calls where you previously got one, and the final error is your own rather than a re-wrapped BasecampError.network(message: "Network error", cause:). To keep an error terminal, throw a non-network case. A single backoff sleep is now capped at 30 s plus jitter.

Compile errors

BasecampError.validation gained a fifth associated value (#549)

case .validation(let message, let status, _, _, let fieldErrors):
    print("Validation (\(status)): \(message)")
    for (field, messages) in fieldErrors ?? [:] { … }

The case is now validation(message: String, httpStatus: Int, hint: String?, requestId: String?, fieldErrors: [String: [String]]?). Add one more _ to every match and every construction — or better, stop matching the case and read the new error.fieldErrors property, defined on every BasecampError (nil for all other shapes), which will not move again when a case gains a sixth value.

TodolistGroup and TodolistOrGroup are gone (#628)

let list = try await account.todolists.get(id: 987654)   // Todolist, not an envelope
if list.groupPositionUrl != nil { /* …and it is a group */ }

Todolist is a strict superset of TodolistGroup's 24 members. The old arm-unwrapping code was already dead: TodolistOrGroup was struct { var todolist: Todolist?; var group: TodolistGroup? } with synthesized Codable, so decoding bc3's flat body found neither key, left both nil via decodeIfPresent, and reported success. Both arms were nil for every real response.

Todolist.description is now let description: String (#628)

Drop the optional handling; a description-less list reads back as "". In Todolist literals, description: moves up into the required block (the emitter sorts required members first) and loses its default. Add "description" to any URLProtocol stub or cassette or decoding throws. One new member is optional and purely additive: groupPositionUrl. The other two new members, color and commentsAppUrl, are required — see the next section.

Todolist.color and .commentsAppUrl are new and required (#637)

public let color: String?           // required, but nullable
public let commentsAppUrl: String   // required and non-optional

Neither member existed on Todolist at v0.12.0 — both arrived with #628 earlier in this release, and #637 landed them in the required block rather than the defaulted one. So this is not an optional member turning required; it is two new members every Todolist(...) literal must pass from the start. The wire is unchanged: bc3 has always emitted both keys, which is why they are required.

The decoder distinction matters for fixtures. color is required and nullable, so the generator emits try container.decode(String?.self, forKey: .color) — an explicit "color": null decodes fine, and only a missing key throws. commentsAppUrl is required and non-nullable (decode(String.self, forKey:)), so it rejects both null and absence. Fixtures rendering "color": null need no change; fixtures omitting either key do.

UpdateTodolistOrGroupRequest.name is required (#574)

Not a new wire restriction — bc3 presence-validates the attribute, so a body omitting name was always a 422. If you do not have the current name to hand, that is the signal to use the merge-safe path.

todolists.update is a merge-safe composite (#574, #628)

let merged = try await account.todolists.update(id: 987654, req: UpdateTodolistRequest(name: "Launch Tasks"))
let replaced = try await account.todolists.replace(id: 987654,
    req: UpdateTodolistOrGroupRequest(description: current.description, name: "Launch Tasks"))
let edited = try await account.todolists.edit(id: 987654) { \$0.name = "🚨 " + \$0.name }

One shape stays silent: _ = try await account.todolists.update(id: 1, req: .init(name: "x")) compiles unchanged, because leading-dot inference resolves .init against whichever type the parameter has and both expose init(description:name:). That call silently becomes two requests with preserve-on-omission semantics.

New pre-write failures on update/edit: a read-back that does not decode is wrapped as BasecampError.api, and a read-back with an empty name throws before any write.

ScheduleEntry.allDay, .endsAt, .startsAt are non-optional (#632)

Remove the optional binding. All three move into the required init block and lose their defaults. New optional members: highlighted and joinUrl — the join link. The request spells the same thing url.

reports.upcoming takes two required arguments and returns reduced types (#648)

// v0.12.0
let r = try await account.reports.upcoming(options: UpcomingReportOptions(windowStartsOn: a))
if let entries = r.scheduleEntries { … }

// v0.13.0
let r = try await account.reports.upcoming(windowStartsOn: a, windowEndsOn: b)
for entry in r.scheduleEntries { … }        // no longer optional

Four separate build failures, and they land in this order:

  • UpcomingReportOptions no longer exists — it was a public struct declared inline in ReportsService.swift, not under Models/, so grep for the name rather than for a file. Both window bounds are now required positional labels; bc3 has always required them.
  • Assignable is deleted (Generated/Models/Assignable.swift is gone), and its replacement UpcomingAssignable spells the field content, not title. assignable.title is value of type 'UpcomingAssignable' has no member 'title' — not a silent nil. bc3 never sent title; the old model was fiction.
  • The three envelope arrays went var … ? to let …scheduleEntries, recurringScheduleEntryOccurrences and assignables are non-optional, so if let on any of them is initializer for conditional binding must have Optional type.
  • Five more new models carry the reduced shapes: UpcomingScheduleEntry, UpcomingScheduleBucket, UpcomingSchedulePerson, UpcomingAssignableParent, UpcomingAssignableCompletion.

This is a fix in the strict sense: at v0.12.0 the call threw DecodingError.keyNotFound on bucket.type for any non-empty window, so the old signature could not return a populated result at all.

Nine operations gained bucketId: (#619), three were removed (#619)

campfires.{listChatbots, getChatbot, createChatbot, updateChatbot, deleteChatbot}, clientApprovals.list, clientCorrespondences.list, clientReplies.{get, list} all take a leading bucketId: Int. clientApprovals.get(approvalId:) and clientCorrespondences.get(correspondenceId:) stay flat.

Removed: recordings.get(recordingId:) (no replacement — see Known gaps), todos.trash(todoId:) (→ recordings.archive(recordingId:) to preserve behaviour, recordings.trash(recordingId:) to actually trash), and forwards.createReply with CreateForwardReplyRequest (no supported replacement — see Known gaps; listReplies/getReply are unaffected).

Behavioural

  • documents.update and schedules.updateEntry are merge-safe composites — see Silent. For schedule entries, the two-tier rule is: full-state fields (summary, startsAt, endsAt, description, allDay) are resent from the read-back when you pass nil, so nil means untouched — but what an explicit value does is per field, not a uniform clear. description is the only one "" clears; "" on summary is accepted and reads back "Untitled"; startsAt/endsAt cannot be cleared at all (bc3 validates_presence_of :starts_at, :ends_at) and take a bare date or a timestamp to match allDay, which is a Bool?*bool in Go, boolean in TypeScript — so "" does not typecheck for it in any SDK. Carve-outs (participantIds, url, highlighted) are omitted entirely when nil so bc3 preserves them, and [], "" and false clear them respectively; the fourth, notify, is a send directive rather than state — omitted when nil, nothing to clear. Recurring entries are out of reach on this route — bc3 302-redirects both show and update for them.
  • downloadURL's first hop now makes three attempts (#563), retrying network errors plus {429, 502, 503, 504} — never 500. Every attempt is authenticated; the signed second hop is still exempt. There is no public numeric knob: DownloadURL is deliberately absent from behavior-model.json. enableRetry: false collapses it to one attempt.
  • cards.update is a single PUT (#647). The signature is byte-identical and DueDate.preserve still exists — it now omits the key instead of fetching the card and resending the value, so a call that used to make two requests and emit two hook events makes and emits one. .clear sends "due_on": "". See Cards: the due-date fix.

TypeScript

TypeScript catches most of this at build time. The exception is the error path — at v0.12.0 every error from a generated service call carried the HTTP status text rather than the server's message, and fixing that silently changed every message string you might be matching on.

Silent

See class A. Two details on fieldErrors that bite:

catch (e) {
  // fieldErrors is a NULL-PROTOTYPE object
  e.fieldErrors.hasOwnProperty("color");   // TypeError: not a function
  Object.hasOwn(e.fieldErrors, "color");   // use this
  for (const [field, messages] of Object.entries(e.fieldErrors ?? {})) markInvalid(field, messages);
}

It is undefined for every status other than 400/422 even when the body carries an errors key, and BasecampError.toJSON() gained a fieldErrors key — a diff in serialised-error snapshot tests.

Compile errors

Nine operations gain a leading bucketId (#619)

await client.campfires.listChatbots(bucketId, campfireId);
await client.campfires.createChatbot(bucketId, campfireId, { serviceName: "deploybot" });
await client.campfires.getChatbot(bucketId, campfireId, chatbotId);
await client.campfires.updateChatbot(bucketId, campfireId, chatbotId, { … });
await client.campfires.deleteChatbot(bucketId, campfireId, chatbotId);
await client.clientApprovals.list(bucketId);
await client.clientCorrespondences.list(bucketId, { sort: "created_at" });
await client.clientReplies.list(bucketId, recordingId);
await client.clientReplies.get(bucketId, recordingId, replyId);

Watch clientCorrespondences.list — the old options object slides into the new bucketId slot and reports TS2345, not TS2554. campfires.list/get/listLines/ createLine and the two flat client-side reads are deliberately untouched.

todos.trash(), recordings.get() and forwards.createReply() removed (#619)

await client.recordings.archive(todoId);  // what todos.trash actually did
await client.recordings.trash(todoId);    // what its name promised
const recordings = await client.recordings.list("Todo", { bucket: [projectId] });

Drop the CreateReplyForwardRequest import — tsc suggests CreateUploadRequest, which is unrelated noise. forwards.createReply() has no supported replacement; see Known gaps.

One flat Todolist replaces Todolist, TodolistGroup and the TodolistOrGroup envelope (#628)

// before
const r = await client.todolists.get(id);
const list = "todolist" in r ? r.todolist : r.group;

// after
const list = await client.todolists.get(id);   // Todolist
const isGroup = list.group_position_url !== undefined;  // a list has groups_url instead

Only the else arm errors: "todolist" in r narrows r to Todolist & Record<"todolist", unknown>, so r.todolist typechecks as unknown and is silent.

The v0.12.0 envelope was fiction — bc3 has always returned the flat record, so "todolist" in r was already false at runtime and r.group was already undefined. Nobody's narrowing ever worked.

If you build group objects, the flat Todolist is a strict superset of the old TodolistGroup, but it requires two members TodolistGroup did not have: add description: "" and description_attachments: [].

Todolist.description is now required (#628)

Construction sites only (TS2741). Readers are unaffected. Also new and optional: group_position_url.

Todolist.color and .comments_app_url are new and required (#637)

Construction sites only, again — TS2741 for each missing member, on top of the description one above. Both members are new in this release (#628) and required from the start (#637), so nothing you wrote against v0.12.0 referenced them. Readers gain certainty rather than losing it: comments_app_url is string, and color is string | null, so it is always present but may be null. null is the ordinary case for a group, so list.color.toUpperCase() still throws — narrow with list.color?.toUpperCase() or an explicit null check. The wire is unchanged; bc3 has always emitted both keys.

UpdateEntryScheduleRequest → two types (#632)

UpdateScheduleEntryRequest (merge-safe; every field optional, plus url? and highlighted?) and ReplaceEntryScheduleRequest (full replace; startsAt and endsAt required, guarded by Errors.validation("Starts at is required") before the request leaves). Most callers want the first — it is a superset of the old field set, so the object literals need no change.

ScheduleEntry.starts_at, .ends_at, .all_day are required (#632)

Construction sites only. New optional members: join_url (the video-call link — read this, not url, which is the entry's own API URL) and highlighted. Note starts_at is a bare date ("2026-06-01") for an all-day entry and a full timestamp otherwise; round-trip it verbatim.

reports.upcoming() takes two required arguments and returns reduced types (#648)

// v0.12.0
const r = await client.reports.upcoming({ windowStartsOn: a, windowEndsOn: b });

// v0.13.0
const r = await client.reports.upcoming(a, b);

upcoming() with no arguments is TS2554 (Expected 2 arguments, but got 0); passing the old object literal is TS2345, because the first parameter is now a string. UpcomingReportOptions is deleted — it was exported from src/generated/services/reports.ts but never re-exported from src/index.ts, so only a deep import references it by name.

components["schemas"]["Assignable"] is gone, replaced by UpcomingAssignable (and UpcomingScheduleEntry, UpcomingScheduleBucket, UpcomingSchedulePerson, UpcomingAssignableParent, UpcomingAssignableCompletion). Two consequences:

  • assignable.title no longer exists — the field is content. bc3 has always sent content, so this is the model catching up with the wire, not a loss. TypeScript reports it; plain JS or a value you widened to any gets undefined, exactly as it already did at v0.12.0.
  • The three envelope arrays — schedule_entries, recurring_schedule_entry_occurrences, assignables — went from optional to required, so r.schedule_entries?.map(…) still compiles but the ?. is now dead, and code that narrowed on their absence has an unreachable branch.

The exported paths type lost nine route keys and re-typed two 422 bodies (#619, #586, #549)

Only affects code that types itself off the root paths export. The trap is the five removed operation slots (TrashTodo, GetRecording, CreateForwardReply, UpdateDocument, UpdateScheduleEntry): their path keys survive because a sibling verb still lives there, so paths["/todos/{todoId}"]["delete"] resolves to never | undefined and compiles clean. Grep for those five names rather than trusting tsc. The 422 re-tags are exactly UpdateMyNote and UpdateMyPreferences.

Five Identity and AuthorizedAccount fields became optional (#681)

Five fields went from required string to optional: Identity.firstName, .lastName, .emailAddress, AuthorizedAccount.product and AuthorizedAccount.appHref. Under strictNullChecks, anything assigning one to a string or calling a method on it is now a compile error:

// v0.12.0 — compiled
const email: string = info.identity.emailAddress;
const initial = info.identity.firstName.charAt(0);

// main — TS2322 / TS18048 ("possibly undefined")
const email: string = info.identity.emailAddress ?? "";
const initial = info.identity.firstName?.charAt(0) ?? "";

This is a correction, not a restriction. Those fields are Launchpad's. bc3 serves its own GET /authorization.json from a different template, and it emits identity.id and nothing else of the identity, and no product or app_href on accounts. You reach that document by passing endpoint: to authorization.getInfo() — the documented way to point at a BC5 issuer — and at v0.12.0 all five were typed string and arrived undefined. The type was lying; it now describes both issuers.

appHref is the one to grep for rather than reason about. discoverIdentity() coerced a missing app_href to "" while AuthorizationService typed it required — so at v0.12.0 the same field was already reaching consumers as an empty string from one call site and as undefined from the other, and only the latter was a type error waiting to happen. It is now honestly optional on both.

AuthorizedAccount.resource (the RFC 8707 indicator, BC5 only) and a top-level AuthorizationInfo.scope are new and optional, so they break nothing.

The same change lands on discoverIdentity(), which returns the same AuthorizationInfo. It had already drifted from AuthorizationService's copy of this mapping — app_href was optional in one and required in the other — and both now share one parser in oauth/authorization-document.ts.

Two behavioural changes ride along, neither of which the compiler will flag:

  • expires_at is read as epoch seconds when it arrives as a number. bc3 renders @token.expires_at.to_i. v0.12.0 passed that integer straight to new Date(), which reads it as milliseconds — so a token expiring in 2036 parsed as a date in 1970, and every "is my token still valid" check said no. A string is still parsed as ISO-8601, unchanged.
  • filterProduct no longer empties the list when the filter cannot apply. A BC5 document carries no product on any account, so getInfo({ filterProduct: "bc3" }) matched nothing and returned [] — while the accounts it was meant to filter existed and were what you needed the href from. When the document carries at least one account and none of them carries a product, the filter is now reported inapplicable: every account is returned and the new AuthorizationInfo.productFilterApplied is false. When at least one account does carry a product the filter applies exactly as before, so an empty result still means "nothing matched". An empty account list — what Launchpad returns for an identity with no currently accessible accounts — reports applied: true: the list is empty either way, and an empty list is no evidence that the issuer omits product.

Go gets the same filterProduct correction and the same two additive fields (AuthorizedAccount.Resource, AuthorizationInfo.Scope, plus ProductFilterApplied); its timestamp already decoded both spellings via FlexTime. Nothing there is a compile break — Go's fields were already zero-valued rather than required. Ruby and Python return the raw document unchanged. See spec/api-gaps/bc5-authorization-document-shape.md.

Behavioural

  • todolists.update() is merge-safe; the one-shot replace is todolists.replace() (#574). Nothing to change to compile. Four decisions: two requests per call; pass description: "" if you relied on omission-clears (replace() requires name); update/edit now throw a statusless BasecampError with code api_error on a malformed read-back; and update(id, { name: "" }) — which v0.12.0 happily PUT and let bc3 422 — now throws Errors.usage("todolist name must not be empty") locally.
  • documents.update() is merge-safe; documents.replace() is the one-shot (#601). UpdateDocumentRequest keeps its exact shape.
  • schedules.updateEntry() is merge-safe with a four-field carve-out (#632). Full state, always resent: summary, startsAt, endsAt, description, allDay. Carve-outs, sent only when the key is present on your object: participantIds, url, highlighted, notify. Addressedness is property presence, not truthiness — the runtime test is Object.prototype.hasOwnProperty.call, so a {...maybeUndefined} spread carrying an explicit undefined counts as addressed. editEntry tracks carve-outs by setter invocation via a Proxy, so e.url = e.url does send the join link.
  • todos.update() and todos.edit() now throw on a malformed read-back (#597). v0.12.0's ?? "" coalesced only null/undefined; a wrong-typed field rode into the replacement PUT. The new error is statusless, code === "api_error", retryable === false, and never reaches the wire. cards.update() was in this set and is no longer — see the next bullet.
  • cards.update() is a single PUT (#647). UpdateCardRequest is unchanged and nothing in your build moves. A call leaving dueOn unaddressed used to fetch the card first; it no longer does, so it costs one request and one hook event instead of two, and dueOn: null goes on the wire as "". This also retires the #597 guard on this method — writableString is not invoked for Cards any more, because there is no read-back to validate. See Cards: the due-date fix.

Python

Python has no compile step: every one of these lands at runtime, in production, on the first call that takes that branch. The package ships py.typed, so mypy/pyright catch the signature changes if you type-check — untyped code finds out on the call.

Silent

See class A, which now includes the three merge-safe composites and the cards collapse — all four have byte-identical keyword sets and no signal of any kind.

One correction worth knowing if you saw an earlier draft: at v0.12.0, a body like {"error": {"code": 7}} did not "hand you the dict" — it raised AttributeError: 'dict' object has no attribute 'encode' inside error_from_response on 400/422/404 and the generic else. Only 401/403 returned the dict. On main all of those return the clean default. That half is a crash fix.

Runtime errors

Nine operations now require bucket_id (#619)

bots      = account.campfires.list_chatbots(bucket_id=project_id, campfire_id=campfire_id)
approvals = account.client_approvals.list(bucket_id=project_id, sort="created_at")
reply     = account.client_replies.get(bucket_id=project_id, recording_id=rec_id, reply_id=reply_id)

TypeError: missing a required argument: 'bucket_id' on all nine and their async twins. client_approvals.get(approval_id=…) and client_correspondences.get(correspondence_id=…) are unchanged — do not add bucket_id there.

todos.trash() removed — and it archived (#619)

account.recordings.archive(recording_id=todo_id)  # preserves what you had
account.recordings.trash(recording_id=todo_id)    # a real change to your data

Decide per call site; do not sed one into the other.

recordings.get() removed (#619)

No generic recording read remains. Use account.todos.get(todo_id=…) / messages.get / documents.get, or enumerate with account.recordings.list(type="Todo", …). See Known gaps.

forwards.create_reply() removed, with CreateForwardReplyRequestContent (#619)

No supported replacement — see Known gaps. Reads are unaffected: forwards.list_replies and forwards.get_reply remain.

TodolistGroup is gone (#628)

from basecamp.generated.types import Todolist

def render(item: Todolist) -> str:
    if "group_position_url" in item:   # a group; a plain list carries groups_url
        return group_label(item)
    return item["description"]         # now required and never null

Discriminate structurally, never on type — it reads "Todolist" for a group and a list alike. description moved from NotRequired[str] to required str; the .get(..., "") guard is dead. description_attachments did not change — it was already required.

Two more members are required, but unlike description they are also new: color (str | None) and comments_app_url (str) did not exist on the v0.12.0 Todolist, arriving with #628 and landed as required by #637. Note the asymmetry — color is always present but may be None, so item["color"] is safe while item["color"].upper() is not.

Everything else about this is silent in Python: basecamp/generated/types.py is imported by nothing in the SDK and every generated service method is annotated -> dict[str, Any]. There is no decoder to throw.

reports.upcoming() requires both window bounds (#648)

# v0.12.0
def upcoming(self, *, window_starts_on: str | None = None, window_ends_on: str | None = None) -> dict[str, Any]
# v0.13.0
def upcoming(self, *, window_starts_on: str, window_ends_on: str) -> dict[str, Any]

Both are now required keyword-only arguments, on the sync method and its async twin. A call that omits either raises TypeError: upcoming() missing 1 required keyword-only argument: 'window_ends_on' at call time — where v0.12.0 reached bc3 and came back a 400. bc3 has always required both.

The return type is unchanged (dict[str, Any]; Python never decodes into the TypedDicts at runtime), so nothing about reading the result changes at runtime. What changes is the static picture: Assignable is deleted from basecamp.generated.types and replaced by UpcomingAssignable and five siblings, and the envelope's three members went NotRequired[list[...]] to required. from basecamp.generated.types import Assignable is an ImportError; everything else here is mypy/pyright-only. In particular result["assignables"][0]["title"] was already a KeyError at v0.12.0 — bc3 never sent title — so #648 changes nothing about it.

With max_retries 0 or 1, a 401 no longer refreshes the token on reads (#571)

# v0.12.0: the refresh replay lived inside _single_request and was UNCOUNTED
client = Client(token_provider=oauth_provider, config=Config(max_retries=1))
account.projects.get(project_id=1)   # 401 → refresh → replay → 200

# main: the replay is a request on the attempt budget, checked BEFORE refresh()
client = Client(token_provider=oauth_provider, config=Config(max_retries=2))
account.projects.get(project_id=1)   # 401 → refresh → replay → 200

Measured on main: max_retries=0AuthError, refreshes=0, one request. max_retries=1 → same. 2 and 3 → OK, refreshes=1. At v0.12.0 all four succeeded.

If you set max_retries (or BASECAMP_MAX_RETRIES) to 0 or 1 and use a refreshable token provider, raise it to at least 2. Reads only: mutations bypass the retry loop and keep the uncounted replay.

Behavioural

  • todolists.update() is a merge-safe GET+PUT (#574). Signature identical. Hooks now observe GetTodolistOrGroup then UpdateTodolistOrGroup. Four things move: two round-trips; ApiError when the GET body is not a dict or name/description is absent/null/non-string/(for name) empty; UsageError (not ApiError) when you pass a non-string, because _caller_string owns caller provenance and _writable_string owns response provenance; and clear-by-omission no longer works — pass description="". replace(id=…, name=…) is the destructive PUT and name is now mandatory.
  • documents.update() is a merge-safe GET+PUT (#601). replace() keeps both params optional here, so omitting title is not a 422 but a 200 that leaves the document reading back as "Untitled".
  • schedules.update_entry() is merge-safe (#632). Every v0.12.0 keyword still binds. Two new keywords: url= (the join link) and highlighted=. Those two plus participant_ids and notify are addressed-only carve-outs: they reach the wire only if you pass them, and ""/[]/False is an explicit clear. Asymmetry: you write the join link as url= but read it back as join_url — the response's own url is the entry's API URL, and echoing it into url= overwrites the join link. replace_entry now requires starts_at and ends_at.
  • page selects a page (#617). Proven with a mock transport that always returns a next link: v0.12.0 get_everything_open_todos(page=3) issued 10,000 requests (the max_pages cap) starting at ?page=3; main issues exactly one and reports meta.truncated=True. Scope is 17 sync methods and their async twins. page=True is explicitly rejected as a selector (bool subclasses int).
  • cards.update() is a single PUT (#647). Keyword set identical. A call leaving due_on unaddressed used to run self.get(card_id=card_id) first and no longer does — one request and one hook event instead of two — and due_on="" is now the clear encoding. The #597 writable_string guard is no longer reached on this method, because there is no read-back to validate. See Cards: the due-date fix.

Ruby

Ruby's breaks are all runtime, and one of them changes when your list methods hit the network — list now performs a request at call time instead of on first iteration, so a rescue placed around only the loop stops catching.

Silent

page: returns exactly that page (#617)

# v0.12.0, against a 4-page stub
drafts = account.drafts.list_my_drafts(page: 2).to_a
#   requests: ?page=2, ?page=3, ?page=4      items: [{id=>2},{id=>3},{id=>4}]

# main, same stub
drafts = account.drafts.list_my_drafts(page: 2).to_a
#   requests: ?page=2                        items: [{id=>2}]

There is no single call for "everything from page 2 onward" any more. Drop page: and slice, or drive the loop yourself. Note that six list methods gained only max_items: and no page: at all: campfires.list_chatbots, checkins.answerers, message_types.list, people.list_pingable, uploads.list_versions, webhooks.list.

max_retries: 0 now sends one request where it sent none (#656)

# v0.12.0 — max_attempts was @config.max_retries, i.e. 0, so the loop broke
# before single_request was ever called
client = Basecamp::Client.new(..., max_retries: 0)
client.get_absolute(url)
#   requests: (none)   raises Basecamp::ApiError, "Request failed after 0 attempts"

# main — the cap is floored at one attempt on every path
client.get_absolute(url)
#   requests: 1

Narrow, and worth reading the scope before auditing anything. Two conditions have to hold together:

  • max_retries must be 0. For any value ≥ 1 the new expression [@config.max_retries, 1].max is byte-identical to the old one.
  • The GET must be ungoverned — carrying no operation ID. Every one of the 249 operations in metadata.json declares a retry block, so this is not "an operation without a policy"; it is a call site that passes no operation:. In practice that means Http#get_absolute and the Launchpad authorization fetch it backs, plus AccountClient#get / Http#get when you call them without naming an operation.

Mutations were never affected — they never entered the retry loop — and the download hop was already floored. If you had max_retries: 0 standing in for a kill switch on those escape hatches, it is not one any more.

ValidationError#message changed; #field_errors is new (#541, #549)

e.message == "Request failed" stops matching. Ruby is unaffected by the cross-SDK "400 now maps to validation" note — Basecamp.error_from_response(400, …) returned ValidationError on both trees. A latent bug also disappears: a 422 whose body was a bare JSON array used to raise TypeError: no implicit conversion of String into Integer out of error_from_response; it now yields a clean ValidationError. A rescue TypeError around error handling is dead code.

Draft#scheduled_posting_at and MyNote#created_at/#updated_at decode to Time (#560)

draft.scheduled_posting_at.start_with?("2026")  # NoMethodError
Time.parse(draft.scheduled_posting_at)          # TypeError
"#{draft.scheduled_posting_at}"                 # "2026-01-02 03:04:05 UTC", was "2026-01-02T03:04:05Z"
draft.scheduled_posting_at.iso8601              # the old string

These are the only three decoder changes in the window. parse_datetime silently rescues to nil, so there is no decode failure to catch. Blast radius is narrow — no service method returns a Basecamp::Types:: object.

Runtime errors

bucket_id: on nine operations (#619)

account.campfires.list_chatbots(bucket_id: 2085958499, campfire_id: 1069479351)
account.campfires.create_chatbot(bucket_id: …, campfire_id: …, service_name: "deploybot", command_url: …)
account.campfires.get_chatbot(bucket_id: …, campfire_id: …, chatbot_id: …)
account.campfires.update_chatbot(bucket_id: …, campfire_id: …, chatbot_id: …, service_name: …)
account.campfires.delete_chatbot(bucket_id: …, campfire_id: …, chatbot_id: …)
account.client_approvals.list(bucket_id: …, sort: "created_at")
account.client_correspondences.list(bucket_id: …)
account.client_replies.list(bucket_id: …, recording_id: …)
account.client_replies.get(bucket_id: …, recording_id: …, reply_id: …)

ArgumentError: missing keyword: :bucket_id the first time each line executes — so a call site behind a rarely-taken branch stays broken until it runs. client_approvals.get(approval_id:) and client_correspondences.get(correspondence_id:) stay flat.

Three removals (#619)

account.todos.trashaccount.recordings.archive(recording_id:) to preserve behaviour, or recordings.trash(recording_id:) to actually trash. account.recordings.get → the type-specific getter or recordings.list(type:). account.forwards.create_reply → nothing; there is no supported replacement (see Known gaps) and list_replies is unaffected.

Basecamp::Types::TodolistGroup no longer exists (#628)

NameError at first reference. Use Basecamp::Types::Todolist; Todolist.required_fields gained :description, plus :color and :comments_app_url — the latter two being members #628 added and #637 landed as required, neither present at v0.12.0. #to_h changed with them: it used to end in .compact, dropping every nil member, and now keeps color even when nil, so a serialized todolist carries "color" => nil rather than omitting the key. Two parallel changes bite identical code: ScheduleEntry.required_fields gained :all_day, :ends_at, :starts_at, and ScheduleEntry gained #highlighted and #join_url. Blast radius is narrow — no SDK service method returns a Types:: object; the service layer returns raw Hashes. This only bites code that constructs these value objects itself.

reports.upcoming requires both window bounds (#648)

# v0.12.0
def upcoming(window_starts_on: nil, window_ends_on: nil)
# v0.13.0
def upcoming(window_starts_on:, window_ends_on:)

account.reports.upcoming with either omitted raises ArgumentError: missing keywords: :window_starts_on, :window_ends_on at call time. bc3 has always required both, so v0.12.0's defaults produced a 400.

The return value is unchanged — a raw Hash — so reading the result behaves exactly as it did. result["assignables"][0]["title"] was nil at v0.12.0 because bc3 sends content, and it is nil now for the same reason. Basecamp::Types::Assignable is deleted (NameError at first reference) in favour of Basecamp::Types::UpcomingAssignable and five siblings, but as with the other Types:: classes, no service method returns one — this only bites code that constructs them itself.

A refreshable 401 no longer replays when max_retries is 0 or 1 (#571)

config = Basecamp::Config.new(max_retries: 2)   # was 1
client = Basecamp::Client.new(config: config, token_provider: oauth_provider)

Measured against a stub that 401s once then 200s: v0.12.0 succeeded at max_retries 0, 1, 2 and 3 alike (two requests, one refresh). Main raises Basecamp::AuthError with zero refreshes at 0 and 1, and succeeds from 2 up. Only bites a refreshing provider (OauthTokenProvider or your own refreshable?-true provider); StaticTokenProvider is unaffected and the default is 3. Reads only — mutations bypass the retry loop.

Behavioural

List methods now request eagerly and return ListEnumerator (#557)

# v0.12.0: fully lazy — 0 requests, 0 hooks until you iterate
enum = account.projects.list
begin
  enum.each { |p| … }          # auth/404 errors surfaced HERE
rescue Basecamp::NotFoundError => e
end

# main: page 1 is fetched inside the call
begin
  enum = account.projects.list  # 1 request, hooks=[start, page:1]; errors surface HERE
  enum.meta.total_count         # X-Total-Count, available already
  enum.each { |p| … }
rescue Basecamp::NotFoundError => e
end

Move the begin above the list call. Stop building enumerators you may not iterate — construction now costs a request and emits an on_operation_start that never gets a matching on_operation_end if you never iterate. ListEnumerator < Enumerator, so each/next/take/first/lazy/to_a are unchanged; only .class assertions move. The total request count for a full traversal is unchanged (page 1 is fetched once either way). Hook-driven metrics need the most care: a re-enumerated list used to emit one start/end pair per pass and now emits one pair total.

Three merge-safe composites

  • todolists.update is GET+PUT (#574). Wire trace on main: GET /1/todolists/3 then PUT /1/todolists/3 {"name":"New name","description":"<div>keep me</div>"}. todolists.replace is the old destructive PUT and name: is now required. todolists.edit(id:) { |list| … } is the read-modify-write form. New: ApiError when the GET body is not a Hash or name/description is missing/null/non-String/(for name) empty. Doc correction: description is writable for a todolist group too, contrary to v0.12.0's doc.
  • documents.update is GET+PUT (#601). documents.replace carries the old signature exactly. title goes through required_writable_string (absent, null, non-String or whitespace-only is refused); content through writable_string.
  • schedules.update_entry is GET+PUT (#632). Full-state fields resent: summary, starts_at, ends_at, description, all_day. Carve-outs omitted unless addressed: participant_ids, url, highlighted, notify. Read the join link back as join_url, never as urlfields_from_entry seeds its url member from join_url because the response's own url is the entry's API URL. Omitting all_day: no longer resets it to false. replace_entry now requires starts_at: and ends_at:.

todos.update/edit refuse a malformed read-back (#597)

v0.12.0's todo["content"] || "" turned a false content into "" and wrote it back — erasing the field on a call that never mentioned it. It now raises Basecamp::ApiError (statusless, retryable: false) before the PUT. Should never fire against a healthy server; it fires instead of silently corrupting the record when one misbehaves. cards.update was in this set at the time #597 landed and is no longer — see the next entry.

cards.update is a single PUT and no longer reads first (#647)

Keyword set identical, so every call site binds unchanged. A call that left due_on unaddressed used to fetch the card first; it no longer does, so it costs one request and one hook event instead of two, and due_on: "" is now the clear encoding — compact_params is kwargs.compact, so the empty string survives to the wire where a nil would not. MergeSafe.writable_string is not reached on this method any more, because there is no read-back to validate. See Cards: the due-date fix.

Two URLs changed (#586)

Loud in a stubbed suite (WebMock raises on an unregistered request), silent against live bc3.

download_url's first hop retries and no longer sends Accept (#563)

# v0.12.0 — one request, Accept: application/json, raised on a 503
response = http.get_no_retry(rewritten_url)

# main — up to three attempts, no Accept header at all
def get_download(url)
  request_with_retry(:get, url, retry_on: DOWNLOAD_RETRY_ON, accept: nil)
end

DOWNLOAD_RETRY_ON is {429, 502, 503, 504} plus network errors — never 500. Two consequences, both silent against a live server:

  • Cassettes and stubs that match on Accept stop matching, because the header is no longer sent at all. accept: nil is load-bearing: request_headers only sets Accept when it is non-nil.
  • Single-shot download stubs see more requests than they expect. A stub that returns one 503 used to surface as an error and now gets retried.

The signed second hop is unaffected. This is the same change Python got, and it is the one download-hop entry that does not apply to Go — Go's download path already retried at v0.12.0.


Kotlin

Kotlin catches the type work at compile time. The things to watch are decoder throws on payloads your fixtures may not carry — including one that no signature change announces at all — and one silent semantic change hidden behind a hand-written compatibility shim.

Silent

documents.update stopped erasing omitted fields (#601)

UpdateDocumentBody was removed from the generator's Types.kt and re-declared by hand in the same package (com.basecamp.sdk.generated.services, file generated-compat/UpdateDocumentBody.kt) with the identical two nullable fields, and documents.update(id, body): Document kept its exact signature. Your call site compiles untouched and now issues GetDocument then ReplaceDocument. UpdateDocument is gone from generated/Metadata.kt. Test doubles that stub only the PUT now fail on the unstubbed GET.

import com.basecamp.sdk.generated.services.ReplaceDocumentBody
account.documents.replace(documentId, ReplaceDocumentBody(title = "Q3 plan"))  // old behaviour
account.documents.edit(documentId) { title = "🚨 $title" }

New failure mode: update/edit throw BasecampException.Api if the fetched document has a blank title.

cards.update stopped fetching the card first (#647)

The signature is unchanged. A call leaving dueOn null used to run get(cardId).dueOn first and no longer does — one request and one hook event instead of two — and dueOn = "" is now the clear encoding, since the generated body builder drops only nulls. See Cards: the due-date fix.

Two URLs changed (#586), error composition changed (#541, #549)

See class A. Two source-compatible widenings ship alongside the error work: BasecampException.Api(httpStatus: Int) became Int? = null, and Validation gained a trailing fieldErrors parameter with a default — every existing construction still binds.

Runtime errors

Todolist.description became required and non-null (#628)

kotlinx.serialization.MissingFieldException: Fields [description, description_attachments]
are required for type with serial name 'com.basecamp.sdk.generated.models.Todolist',
but they were missing at path: $

That is the real message, captured by decoding v0.12.0's own group fixture against main's model. Note it is two fields, not one: description_attachments was already required on Todolist at v0.12.0, and the old TodolistGroup had neither member — so a captured group payload is missing both.

Re-record group payloads with "description" (a string, "" for empty) and "description_attachments" (an array, [] for none). Plain todolist fixtures usually need nothing — v0.12.0's own spec/fixtures/todolists/get.json already carried "description": "".

Two further members are required, color and commentsAppUrl, both declared without a default (#628 added them, #637 made them required before the release shipped — neither existed at v0.12.0). They differ in nullability, and the difference is exactly what your fixtures hit: color is String?, so an explicit "color": null is accepted and only a missing key raises MissingFieldException; commentsAppUrl is String, so it rejects null and absence alike. A fixture already rendering "color": null is fine as-is.

A missing key gives MissingFieldException; an explicit "description": null gives JsonDecodingException, because description is required and not nullable. The client's coerceInputValues = true rescues neither. The throw escapes as a raw SerializationException from todolists.get/replace/list/create and todolistGroups.list/createcatch (e: BasecampException) does not see it. The merge-safe todolists.update/edit are the exception; they wrap it into BasecampException.Api.

A wrong-typed scalar no longer decodes into a String (#660)

// v0.12.0 — isLenient = true on the client-wide Json
// {"description": 42}     -> description == "42"
// {"description": false}  -> description == "false"

// v0.13.0 — isLenient is gone
// kotlinx.serialization.SerializationException

Nothing in your build tells you. No type, field or method signature moved; the only change is one line of Json { } configuration in BasecampClient. That Json backs every response decode in the SDK, so the scope is every String/String? member on every model, not a named list of fields. isLenient also relaxed other RFC-4627 rules — unquoted literals — so a body relying on that laxity now fails too. Structural mismatches ([] or {} where a string is declared) were already refused and are unchanged, as is coerceInputValues, which stays: it rewrites an explicit null to a declared default and has nothing to say about a scalar's type.

Two things to plan around:

  • The trigger is a present, populated, wrong-typed field. Absence and explicit null behave exactly as they did, so a fixture that omits the field proves nothing. Re-record any cassette carrying a number or boolean in a string slot.
  • On a write, the mutation has already happened. The throw is in the response decode, so cards.update issues its PUT, the card changes, and then it raises. And it raises a raw SerializationException, not a BasecampExceptioncatch (e: BasecampException) does not cover it. The merge-safe todolists.update/edit are the exception; they wrap it as BasecampException.Api.

This is the fix for a real corruption path: under isLenient, a merge-safe composite read "description": 42, got the string "42", and wrote that fabricated value back to a full-replace endpoint on a call that never mentioned the field. The per-composite guard that fixed Python, Ruby and TypeScript in #597 could not see it, because the coercion happened inside the decoder.

ScheduleEntry.allDay, .startsAt, .endsAt became required and moved (#632)

Same decoder throw for payloads that omitted them. The SDK's own spec/fixtures/schedules/entry_get.json already carried all three, so this bites hand-built JSON and cassettes recorded from reduced renderings, not the shipped recordings.

The three fields also moved from the tail of the constructor to positions 13–15, so positional construction of a ScheduleEntry (test fakes) and componentN destructuring past position 12 break at compile time. Switch to named arguments. Treat startsAt/endsAt as opaque strings — a bare date for an all-day entry, a full timestamp otherwise.

Compile errors

TodolistGroup is gone (#628)

import com.basecamp.sdk.generated.models.Todolist

val groups: ListResult<Todolist> = account.todolistGroups.list(todolistId)
val created: Todolist = account.todolistGroups.create(todolistId, CreateTodolistGroupBody(name = "Phase 1"))

The accessor, the method names and CreateTodolistGroupBody are unchanged.

todolists.get returns Todolist, not JsonElement (#628)

Delete the hand-rolled jsonObject[...]!!.jsonPrimitive.content digging. There is no JsonElement escape hatch on this method any more.

todolists.update takes a different body; replace is the destructive PUT (#574, #628)

import com.basecamp.sdk.generated.services.UpdateTodolistBody

account.todolists.update(todolistId, UpdateTodolistBody(name = "Hardware"))     // merge-safe
account.todolists.edit(todolistId) { name = "🚨 $name" }
account.todolists.replace(todolistId, UpdateTodolistOrGroupBody(name = "Hardware", description = ""))

Same field names, both nullable, but null now means "leave alone". UpdateTodolistOrGroupBody.name also became non-null and required, so UpdateTodolistOrGroupBody(description = "x") no longer compiles. Hooks see GetTodolistOrGroup then UpdateTodolistOrGroup — the wire operation name did not change, so a hook keyed on those keeps firing but fires twice as often, and an allowlist that omits GetTodolistOrGroup now denies the read half.

UpdateScheduleEntryBody no longer exists (#632)

account.schedules.updateEntry(entryId, summary = "Weekly sync")
account.schedules.editEntry(entryId) { summary = "Weekly sync" }
account.schedules.replaceEntry(entryId, ReplaceScheduleEntryBody(
    summary = "Weekly sync",
    startsAt = "2026-08-10T09:00:00.000Z",   // now mandatory
    endsAt   = "2026-08-10T10:00:00.000Z",
))

Flatten the old body's fields into named arguments; declared order is summary, startsAt, endsAt, description, allDay, participantIds, url, highlighted, notify. Every argument is nullable and null means "do not address", so a call that used to erase participants by omission now preserves them — clear deliberately with participantIds = emptyList(), url = "", highlighted = false.

Nine operations gained a leading bucketId (#619)

campfires.{listChatbots, createChatbot, getChatbot, updateChatbot, deleteChatbot}, clientApprovals.list, clientCorrespondences.list, clientReplies.{list, get}. The new parameter is a Long in first position, so a call passing the same number of arguments can bind wrongly and fail on a later parameter — check each site rather than trusting the error text. listChatbots did not gain a second overload, so the ambiguity break below does not apply to it.

Three operations removed (#619)

recordings.get (no replacement — see Known gaps), todos.trash (→ recordings.trash(todoId), which is what the name promised; recordings.archive is what the old call actually did), forwards.createReply (no supported substitute — see Known gaps; CreateForwardReplyBody is gone from Types.kt with no compat shim).

reports.upcoming takes two required arguments and returns a typed result (#648)

// v0.12.0
suspend fun upcoming(options: GetUpcomingScheduleOptions? = null): JsonElement
// v0.13.0
suspend fun upcoming(windowStartsOn: String, windowEndsOn: String): UpcomingScheduleResult

Three build failures. upcoming() no longer has a default argument, so a bare call is unresolved; GetUpcomingScheduleOptions is deleted from generated/services/Types.kt (and from options-param-order.json); and the return type is no longer JsonElement, so every .jsonObject["schedule_entries"] navigation stops compiling. assignable.title is an unresolved reference — the member is content, which is what bc3 has always sent.

UpcomingScheduleResult and its six models are @Serializable with non-nullable required members, so a body missing any of them now throws MissingFieldException where v0.12.0 handed back a JsonElement unconditionally. That is deliberate, and it is the same fixture hazard as the two entries above.

Untyped callable references to 22 list methods are now ambiguous (#617)

val fetch = account.documents::list   // Overload resolution ambiguity

val fetch: suspend (Long, PaginationOptions?) -> ListResult<Document> = account.documents::list

Each method now has two overloads: a primary taking the operation's own List…Options (which carries page, non-nullable, no default) and a source-compatibility overload keeping PaginationOptions? = null. Ordinary calls — list(id), list(id, null), list(id, PaginationOptions(maxItems = 50)) — resolve unchanged. The 22: boosts.listForRecording, boosts.listForEvent, campfires.list, cards.list, checkins.{reminders, listQuestions, listAnswers, byPerson}, clientReplies.list, comments.list, documents.list, events.list, forwards.listReplies, gauges.listGaugeNeedles, people.{list, listForProject}, reports.{progress, personProgress}, timeline.projectTimeline, todolistGroups.list, uploads.list, vaults.list.

Behavioural

  • page selects a page (#617) — 17 operations, no build-time signal.
  • downloadURL retries hop 1 (#563)DOWNLOAD_RETRY_ON = {429, 502, 503, 504} plus network errors, gated on config.enableRetry (default true) with maxRetries (default 3). A single-shot 503 mock now sees three requests. 500 is deliberately not retried.

Coverage: corrected and re-scoped

This section is about what did not ship. The coverage work landed a revised scope, not the originally planned one.

#581/#582 — one route modelled, five retired as phantoms (#626)

Of the six routes tracked, only DELETE /timesheet_entries/{id} was a real gap. It ships as DestroyTimesheetEntry (204, naturally idempotent; 403 when the caller may not archive or trash the entry). The other five were already modelled at their flat spelling and were never missing:

RouteAlready modelled as
GET/PUT/DELETE /projects/{id}/gauge/needles/{id}GetGaugeNeedle / UpdateGaugeNeedle / DestroyGaugeNeedle
GET /projects/{id}/recordings/{id}/timesheetGetRecordingTimesheet
POST /projects/{id}/recordings/{id}/timesheet/…CreateTimesheetEntry

bc3 draws each of those twice, both draws name the same controller action, and bc3's own API tests assert the two return identical data. They surfaced in the gap ledger only because the comparison key collapses a leading /buckets/{id} and nothing else. Tracking them was a promise to ship six duplicate operations across six SDKs for no reachable capability.

They now carry a modeled_as: disposition in spec/bc3-route-allowlist.yml, and the disposition is checked rather than asserted — "we already model that at the other spelling" is precisely the claim that shipped ListForwards and RepositionTodolistGroup as 404s. The gate requires the named operation to exist, share the verb, sit elsewhere, be documented at that elsewhere in bc3's route table, and have this path with a leading scope removed — a suffix test, not a subset test.

Net: coverage moved by one operation, not by six.

Dock-door creation: deferred (#627)

POST /buckets/{id}/dock/doors is a real API route and keeps its registry disposition. The blockers are cost, unchanged — now recorded as a closed decision rather than an open question. It is not modelled in v0.13.0.

Its sibling, GET /buckets/{id}/dock/doors/{id}, moved to out_of_scope: it is a redirector (redirect_to @recording.door.url), so modelling it would make a credentialed client follow a redirect to a user-supplied third-party URL in four of six transports that follow redirects by default — for a value ListRecordings already returns as a plain field.

Schedule-recurrence writes: deferred (#627)

What shipped is the pre-modelling gate, not the modelling. Recurrence writes remain unmodelled. Reading the source at the pin corrected the ledger entry twice: bc3 #12362 does not produce a wire-visible validation error — it makes valid? false and the record takes the pre-existing silent-discard path, so week_instance: 0 returns 201 with a non-recurring entry — and the read side does not already model recurrence_schedule; there is no such shape in spec/basecamp.smithy or openapi.json, so absorption must add the output structure too. The decision is now approvable rather than blocked.


Known gaps

recordings.get has no generic replacement

GetRecording was removed with nothing equivalent behind it (see Route corrections). If you hold a recording ID and already know its type, use the type-specific getter — todos.get, messages.get, documents.get, and so on. That is the intended path and it is strictly better than the old call, which 404'd.

If you hold an ID whose type you do not know, there is no direct read. The only route left is list-and-filter, and it is expensive enough that you should treat it as a last resort rather than a migration:

// Go — scan one type at a time; there is no "any type" list.
for _, t := range []basecamp.RecordingType{
    basecamp.RecordingTypeTodo, basecamp.RecordingTypeMessage, basecamp.RecordingTypeDocument,
    basecamp.RecordingTypeComment, basecamp.RecordingTypeUpload, basecamp.RecordingTypeVault,
    basecamp.RecordingTypeScheduleEntry, basecamp.RecordingTypeQuestionAnswer,
    basecamp.RecordingTypeTodolist, basecamp.RecordingTypeKanbanCard,
    basecamp.RecordingTypeKanbanStep, basecamp.RecordingTypeDoor,
} {
    res, err := ac.Recordings().List(ctx, t, &basecamp.RecordingsListOptions{
        Bucket: []int64{projectID},   // narrow it if you can
        Status: "active",             // "archived" and "trashed" need separate passes
    })
    if err != nil { return err }
    for _, r := range res.Recordings {
        if r.ID == wanted { return handle(r) }
    }
}

Costs to be clear about: twelve list operations, each paginated across the whole account unless you can narrow Bucket; Status defaults to "active", so an archived or trashed recording needs additional passes; and the polymorphic Recording projection is thinner than the type-specific one, so you may need a second, typed read once you know the type.

The durable fix is on your side: persist the type next to the ID. Every recording the API hands you carries typeRecording.Type in Go, recording["type"] elsewhere — and it is the discriminator the type-specific getters need. An ID stored without its type is an ID you cannot cheaply resolve, and that was already true at v0.12.0; the removed operation did not resolve it either, because it 404'd.

This is a real gap and it is recorded as one, not papered over.

There is no raw-wire migration path

CreateForwardReply was removed with nothing behind it. The flat route it declared was never drawn; a bucket-scoped create does exist upstream, but it is undocumented and has no upstream coverage, so it is not modelled here.

Do not reach for the raw client to reinstate it. Hand-building a path and calling Client/AccountClient Get/Post/Put/Delete — or the equivalent in any other SDK — gives up everything the generated operation owns: the path and verb, the observability hooks, the retry and idempotency configuration, response decoding, and the error mapping described throughout this guide. It also pins your code to a route nothing in this repository tests, so it can break without any signal from a release. The repository holds its own contributors to the same rule (AGENTS.md, "Never Do These" §4 and §5: never construct API paths manually, never bypass the SDK).

If you need this operation, the fix is to model it: open an issue, or add the bucket-scoped route to spec/basecamp.smithy and regenerate. That is a change to the SDK, not a workaround inside your application.

The same applies to any other removal in this release. Where a supported replacement exists — recordings.archive for todos.trash, the type-specific getters for recordings.get — it is named in the per-SDK section. Where none exists, that is stated plainly and no substitute is implied.

Binary compatibility on the JVM and in Swift was not assessed

Everything in this guide is about source compatibility. Nobody measured whether a binary compiled against v0.12.0 links against v0.13.0.

For Kotlin, kotlin/README.md has always disclaimed binary compatibility — default-argument and data-class synthetics make it infeasible to promise. This release gives that disclaimer plenty of work: data-class copy and componentN signatures changed, and ScheduleEntry moved three constructor parameters into positions 13–15. Recompile. Do not drop a v0.13.0 jar under a binary built against v0.12.0 — you can hit NoSuchMethodError at runtime where the source would have compiled clean.

That README's source-compatibility wording did change in this release, because v0.13.0 broke it. It previously promised that 0.x public APIs evolve append-only, so code compiles unchanged across minor versions, with one carve-out for endpoints Basecamp withdraws. This release violates that repeatedly and deliberately — TodolistGroup and UpdateScheduleEntryBody removed, Todolist.description and three ScheduleEntry members made required, a leading bucketId added to nine operations — and none of it is a withdrawn endpoint. Each is a correction to a model that described bc3 wrongly. The policy now says what the project actually does: append-only by default, but a minor version may break source compatibility to fix the model, and when it does the breaks are documented here and the release carries the breaking label.

There is deliberately no binary-compatibility-validator .api dump in kotlin/; adding one would imply a guarantee the project does not make.

Swift has no equivalent written policy. The same caution applies for the same reason: member ordering changed on Todolist and ScheduleEntry, memberwise initializers moved parameters between the required and defaulted blocks, and the SDK is distributed as source through SPM, so the ordinary consumer recompiles anyway. If you vend a prebuilt .framework or an .xcframework built against v0.12.0, rebuild it.

For a 0.x release this is an acceptable position — but it is a position, not an oversight, and it should be stated rather than assumed.


Not in this release

Every change the # Unreleased section describes was merged by fa15fc126, and the in-flight set below was surveyed at that commit; the historical sections' counts keep the baselines they themselves state (v0.13.0's totals were measured at 9a819e44d, as that section says). In flight at that commit, and therefore not in this release: the event-feed connector stack (#777, #705, #778 — SPEC §23's Go reference implementation and its conformance driver) and two long-running drafts (#802, SPEC §9's peer-derived-text boundary; #238, the API-disabled 404 Reason header). The record below dates from this guide's v0.13.0 drafts and remains true as written.

For the record, since the earlier drafts named them and reviewers may be looking for them:

  • #647 — Cards: the due-date fix (46b7f8225). Folded into Cards: the due-date fix and into a class-A entry for all six SDKs, plus a Go compile-error entry for UpdateStepRequest.DueOn. One draft claimed it would have to go Smithy-first; the merged commit touches no schema at all, and the UpdateCardStepRequestContent.DueOn pointerization that prompted that claim came from #560.
  • #648 — the GetUpcomingSchedule projection (#635, #641, #644 — e0431a722). One compile-or-runtime entry per SDK. It adds no class-A or class-B entry anywhere: bc3's response body is byte-identical before and after, so nothing that used to be populated silently stops being so, and every rename and retype is caught statically in Go, Swift, TypeScript and Kotlin and raised immediately in Python and Ruby. The operation inventory does not move — 247 on both sides, same 14 added / 5 removed / 11 route-moved delta from v0.12.0. It does not fix ScheduleEntry.join_url/.highlighted, which were optional only because GetUpcomingSchedule shared the shape; #648 retires that reason without tightening them, so they are now under-modelled rather than correctly modelled. Tightening touches five other operations and every inline stub in six SDKs, so it is deliberately its own diff. #641's three additive members on CreateScheduleEntryurl, highlighted, status — ride along; the read/write spelling split stands, write url and read join_url.
  • #652 — the projected-example gate (#638 — fd939d0bb). Repository-internal: it validates the projected examples against the schema the projection publishes. Nothing consumer-visible, no operations added.

Between them #648 and #652 took make check from 41 targets to 43 (smithy-mapper-test, then check-projected-examples). Derive it with:

sed -n 's/^check-targets: *//p' Makefile | tr ' ' '\n' | grep -c .

Six PRs landed after this guide's first draft. All are merged, all are inside the counts above, and none is pending. They are listed because a reviewer comparing the guide against git log will find them and should not have to work out which ones mattered:

  • #676 — Python entered the security tooling (b0567a77e). Repository-internal: CodeQL, Trivy and ruff's flake8-bandit cover Python for the first time. No consumer-visible change and no operations.
  • #679 — bc3 repin plus project archive/unarchive (a5bcb3fb2). Adds ArchiveProject and UnarchiveProject, taking the inventory from 247 to 249. It adds no class-A and no class-B entry, for the reason new operations never do: there was no v0.12.0 caller of an operation that did not exist.
  • #678 — Link-header parsing (a6aa8a1c2). All six SDKs parsed Link wrongly, in two different directions, and that part is a fix rather than a break. Its breaking label is earned by the maxPages validation it also introduced — see What shipped.
  • #645 — event-feed conformance (7a32b22c3). Conformance families and the SPEC §23 G-SD repair. Test-suite scope only; it moves no SDK surface, and it adds conformance families rather than make check targets, so the 43 above still holds.
  • #610 — Actions group bump (9f0ebad16). CI only.
  • #680 — two maxPages guards that disagreed with their siblings (9a819e44d). Fixes, not breaks: Python stopped accepting max_pages=True, and the TypeScript factory stopped rejecting a null that a directly constructed service defaulted. Both are described under What shipped.

Also folded in rather than listed: #637 (Todolist.color and comments_app_url required — 0fd25079c), #643 (basecamp.Ptr and basecamp.Deref51d0d86cf), #629 (bare field-map error bodies plus the cloud-file and Google-document operations — a373b004c, which took the inventory from 241 to 247), #656 (Ruby's floored attempt cap — 0fa8b461f), #658 (five more Go wrapper timestamps — 6a8a833b3), #660 (Kotlin's decoder strictness — a3174cf3e) and #664 (bare dates on schedule-entry creation — 2afc97707).