Skip to content

SDK v4 - #461

Merged
razor-x merged 82 commits into
mainfrom
beta
Aug 20, 2026
Merged

SDK v4#461
razor-x merged 82 commits into
mainfrom
beta

Conversation

@razor-x

@razor-x razor-x commented Aug 13, 2026

Copy link
Copy Markdown
Member
  • feat: unify the SDK with the Seam SDKs for other languages
  • refactor: keep the exception classes in the Seam namespace
  • fix: namespace the generated nested resource classes
  • refactor: share the Ruby SDK's merge-properties verbatim
  • fix: lock dependencies against the minimum supported PHP
  • feat: drop support for PHP 8.1
  • docs: call endpoint methods with named arguments
  • refactor: rename the multi workspace client to without workspace
  • refactor: make the client the Guzzle client
  • ci: construct the client the install check smoke tests
  • feat: read the personal access token and workspace id from the environment
  • fix: return a list of action attempts as a list
  • fix: keep a retried request from duplicating a write
  • fix: reject an option that a preconfigured client would silently discard
  • feat: remove the lts_version method
  • test: specify that a 503 on a read should be retried
  • 4.0.0-beta.1
  • Fix prune workflow job name

razor-x and others added 30 commits August 12, 2026 22:37
The Python, Ruby, and JavaScript SDKs share a runtime core, a common README
skeleton, and a test suite aligned to the same baseline. This SDK had the
codegen and release tooling but not the runtime surface. This brings it in
line.

Added:

- Personal access token authentication, with the seam-workspace header, and
  token format validation that rejects client session tokens, JWTs, and
  publishable keys with a specific message.
- from_api_key, from_personal_access_token, and from_client factories.
- SeamMultiWorkspace for the endpoints that are not scoped to a workspace.
- SeamWebhook, verifying incoming webhooks with svix.
- SEAM_ENDPOINT support, plus the deprecated SEAM_API_URL and its warnings.
- Retries, two by default with exponential backoff, via
  caseyamcl/guzzle_retry_middleware. A request that never reached the server
  is always retried; a status code is only retried for idempotent methods,
  since retrying a POST the server may already have processed could duplicate
  a write. The other Seam SDKs make the same trade.
- HTTP layer configuration: guzzle_options, retries, and an injectable client.
- A client level wait_for_action_attempt default, accepting a bool or a
  timeout and polling_interval.
- A test suite covering auth, env, headers, errors, malformed responses,
  retries, pagination, serialization, action attempts, and webhooks, run
  against @seamapi/fake-seam-connect.
- Psalm, wired into composer lint.

Fixed:

- Responses in the 3xx range were treated as successful.
- The Seam error check accepted any body with a truthy error key. It now
  checks the content type and that error.type and error.message are strings,
  matching the other SDKs.
- throw_http_errors let Guzzle throw before the SDK could map the error,
  making the whole error mapping unreachable. The option is gone.
- Malformed JSON silently decoded to null and then failed on property access.
- Non-Seam error responses raised an exception built from a fabricated
  request rather than the real one.
- getRequestId returned an empty string rather than null when the header was
  absent, and the fallback error type was unknown rather than unknown_error.
- HttpInvalidInputError never actually overrode the error code, and the
  action attempt errors wrote to an undeclared property.
- Paginator::firstPage indexed its cache unconditionally, and the null cursor
  guard was unreachable.

BREAKING CHANGE: The client is Seam\Seam; Seam\SeamClient remains as a
deprecated alias. The constructor takes named options, so endpoint is no
longer the second positional argument, and throw_http_errors is removed.
Exceptions moved to the Seam\Exceptions namespace. poll_until_ready is
removed in favor of wait_for_action_attempt, whose defaults change from
20s/0.4s to 10s/1s. $seam->client is a Seam\Http\SeamHttpClient rather than a
Guzzle client. The $api_key property and the global LTS_VERSION constant are
removed. Responses in the 3xx range are no longer treated as successful.
Requests are now retried. Pagination metadata is a Seam\Pagination object.
PHP 8.1 or later is required, and svix/svix is a new dependency.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fwgphpc9YhtxBbxuxppy8m
The core public API is small enough to read at a glance, so nesting part of
it under Seam\Exceptions bought organization it does not need.

Keeping the classes where 3.x had them also means existing catch blocks keep
working. Sub-namespacing errors is the more common PHP convention, but the
Python and JavaScript SDKs both export theirs at the package root, so this is
closer to them as well.

The new SeamException marker interface, InvalidOptionsError, and
InvalidTokenError are all that changes for a caller upgrading from 3.x.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fwgphpc9YhtxBbxuxppy8m
A nested class was named after the root resource plus the property, but the
base name was never replaced as the recursion descended, so every shape below
the first level competed for one name and addClass silently kept whichever was
generated first.

device.properties.battery and device.properties.accessory_keypad.battery both
resolved to DeviceBattery. The keypad won, so the device battery was generated
with only level and $device->properties->battery->status did not exist. The
same collapse hit the climate preset metadata, which took the shape of
properties.ecobee_metadata and lost climate_ref, is_optimized and owner, and
the phone_session credential and entrance metadata pairs.

The flat map is now a recursive tree. A nested class is named after its
property alone and declared in the namespace of the class that owns it, so the
two batteries are Device\Properties\Battery and
Device\Properties\AccessoryKeypad\Battery. Properties reference their nested
classes relatively, letting PHP resolve them from the owning namespace. This
also keeps Seam\Resources free of the hundreds of names that existed only to
type a property.

Where the old code silently overwrote, codegen now throws: on two siblings
producing the same class name, on a name PHP reserves as a type, and on nesting
deeper than 16 levels, which means a cyclic schema rather than a real shape.

Merging a discriminated union moves to codegen/lib/merge-properties.ts, which
unions by name recursively rather than taking the first occurrence, so a merged
class keeps every variant's fields. A merged property keeps its description
only when every variant that documents it agrees, because each variant
documents the property for its own case and that text is not necessarily true
of the class the variants collapse into. Deprecation is now deprecate-if-any,
since first wins could undeprecate a field depending on blueprint ordering.
This drops the description on is_device_error, which claimed the error is not
a device error on every variant including the ones where it is, while
is_bridge_error keeps the text all variants share.

Resource classes are emitted as one braced namespace block per namespace in a
single file per resource, which works because src/Resources is autoloaded by
classmap rather than PSR-4. The resource and property docblock helpers are
indented one level deeper to match.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fwgphpc9YhtxBbxuxppy8m
The merge module was written from the specification rather than copied, so it
had drifted from the Ruby SDK in details that matter for keeping the two in
step: the merged list was not sorted, the path in a list recursion omitted the
[] segment, the format list in the disagreement error was sorted, and the error
messages were worded differently.

Take codegen/lib/merge-properties.ts from seamapi/ruby as it stands, so the two
SDKs share one implementation and a future change to the semantics is a single
diff to port rather than a reconciliation.

Only the sort is observable here: it reorders the properties of the two merged
resources, which reorders the nested classes emitted for them. The class and
namespace sets of ActionAttempt and Event are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fwgphpc9YhtxBbxuxppy8m
composer.json requires php ^8.1 but set no resolution platform, so the lock was
resolved against whatever PHP generated it. Generated on 8.4, that selected
Symfony 8.x, which requires php >=8.4.1, and composer install then refused the
lockfile on 8.1, 8.2 and 8.3:

  Your lock file does not contain a compatible set of packages.
    - symfony/console is locked to version v8.1.2 ...
    - symfony/console v8.1.2 requires php >=8.4.1 -> your php version
      (8.2.33) does not satisfy that requirement.

Pin config.platform.php to the oldest PHP this package supports so the lock
represents that platform rather than the machine that happened to write it.
Symfony drops to 6.4 LTS; Psalm and PHPUnit are unchanged.

The patch version is 8.1.31 because Psalm 6 requires ~8.1.31 on the 8.1 line.

The Install jobs passed throughout because they synthesize a composer.json and
never read this lockfile, which is why only Test and Lint caught it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fwgphpc9YhtxBbxuxppy8m
PHP 8.1 reached end of life in December 2025, so the oldest version this
package supports is now 8.2, which has security support through 2026.

Raise the floor in composer.json, move the resolution platform to 8.2.27, the
patch Psalm 6 requires on the 8.2 line, and drop 8.1 from the CI matrices.
Symfony moves up to 7.4 now that 8.2 is the target.

BREAKING CHANGE: PHP 8.2 or later is required.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fwgphpc9YhtxBbxuxppy8m
PHP cannot declare a parameter as name-only the way Python's keyword-only
marker and Ruby's required keyword arguments do, so the calling convention can
only be documented rather than enforced.

Parameter order comes from the API definition, so an endpoint that gains a
required parameter can reorder the ones already there, and a positional call
then binds a value to the wrong parameter with nothing to catch it. Every
example now passes arguments by name, and the usage section says why.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fwgphpc9YhtxBbxuxppy8m
The JavaScript SDK renamed this client to SeamHttpWithoutWorkspace and left
SeamHttpMultiWorkspace behind as a deprecated alias, since what the class
actually does is reach the endpoints that take no workspace in scope rather
than several workspaces at once. This SDK is introducing the class now, so it
can start from the current name with no alias to carry.

Seam\SeamMultiWorkspace becomes Seam\SeamWithoutWorkspace, mirroring Seam\Seam
the way the JavaScript name mirrors SeamHttp. The auth helper and the README
section follow, so multi workspace is gone as vocabulary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fwgphpc9YhtxBbxuxppy8m
$seam->client was a wrapper that had to be unwrapped with get_client() to reach
the Guzzle client underneath. It is now the Guzzle client itself, so anything
Guzzle can do is reachable without a detour, and there is one client object in
the public surface rather than two.

Error mapping moves to Guzzle middleware, which fits better than the wrapper
did: it sits outside the retry middleware, so it only sees the response a
request finally settled on, and it holds the real request rather than a
fabricated one when raising a transport error. Reading the response body moves
to Seam\Http\Body, called by the generated route methods.

The timeout drops from 60 to 30 seconds and becomes an option of its own rather
than something to bury in guzzle_options, alongside retries. It covers
connecting as well as reading.

Seam::request() is gone; use $seam->client->request().

BREAKING CHANGE: $seam->client is now the Guzzle client, so
$seam->client->get_client() no longer exists, and Seam\Http\SeamHttpClient is
replaced by Seam\Http\ClientFactory. Seam::request() is removed. Requests now
time out after 30 seconds rather than 60.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fwgphpc9YhtxBbxuxppy8m
The install check builds the package, requires it from a scratch project and
constructs a client to prove the published archive autoloads. It still named
Seam\SeamClient, which no longer exists, so the check failed on every PHP
version once the alias went away.

Construct Seam\Seam instead, by name, matching how the README documents calls.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fwgphpc9YhtxBbxuxppy8m
…nment

SEAM_PERSONAL_ACCESS_TOKEN and SEAM_WORKSPACE_ID now fall back into place
the way SEAM_API_KEY already did, so a client can be constructed with no
arguments under either authentication method. Defining both credential
variables at once is ambiguous and raises an InvalidOptionsError.

SeamWithoutWorkspace reads SEAM_PERSONAL_ACCESS_TOKEN as well.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fwgphpc9YhtxBbxuxppy8m
Whether a method resolves its result as an action attempt was keyed on
the return resource alone, so the list endpoint wrapped its entire
action_attempts array in a single ActionAttempt and piped it through the
resolver, which broke every call to it: an empty list decoded to null
and a populated one polled an attempt with a null id. The array response
now generates like any other list endpoint, which also restores the
on_response hook the resolver branch skipped, so the paginator gets its
pagination metadata.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fwgphpc9YhtxBbxuxppy8m
A timeout can fire while waiting on a response to a request the server
received and is still processing, so retrying it can repeat a write,
such as unlocking a door twice. Transport errors on a method that is not
safe to repeat now go through a dedicated middleware that skips anything
that looks like a timeout, while idempotent methods keep retrying every
transport error. Connection resets keep retrying either way.

The error mapping middleware also sat inside the redirect middleware,
where raising on a 3xx made following redirects impossible even when
asked for. It is unshifted to the outside of the stack, so a redirect is
followed rather than raised and only an unfollowed one is an error.

The handler stack a caller passes in is cloned rather than mutated, so
building a second client from the same options no longer stacks the
middleware twice and multiplies the retries. A bare handler, such as a
MockHandler, is wrapped in a stack so the error mapping and retries
apply to it instead of being silently dropped. And a response body that
cannot seek, such as a streamed response, is read as is rather than
failing on rewind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fwgphpc9YhtxBbxuxppy8m
A credential or endpoint passed beside a client was dropped without a
sound, since the client carries its own, so requests went out with
whatever authorization the client held. The combination now raises an
InvalidOptionsError naming the offending option, on both Seam and
SeamWithoutWorkspace; wait_for_action_attempt stays allowed since it
does not configure the client.

Also swept up along the way:

- The paginator replaced a caller's on_response callback with its own
  instead of chaining the two, so the caller's silently never fired.
- WorkspacesProxy forwarded to the generated create positionally, the
  exact silent mis-binding the README warns about, and now uses named
  arguments.
- The reserved name check in codegen only knew PHP's type names, so a
  property named list or default would have generated a class that
  cannot parse; it now covers the reserved keywords too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fwgphpc9YhtxBbxuxppy8m
It only echoed the LTS_VERSION constant, and its snake_case sat oddly
beside createPaginator on the same class. Use Seam::LTS_VERSION.

BREAKING CHANGE: lts_version() is removed from Seam and
SeamWithoutWorkspace. Read the LTS_VERSION class constant instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fwgphpc9YhtxBbxuxppy8m
Repeating a read is safe, but every SDK call currently goes over POST,
where a status based retry never is, so the SDK's own reads get none.
The test asserts today's behavior and marks itself incomplete; once the
SDK issues GET for the endpoints that support it, planned for a followup
PR, the incomplete branch stops matching and the real assertions take
over.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fwgphpc9YhtxBbxuxppy8m
feat: Implement Seam standard SDK interface
seambot and others added 23 commits August 19, 2026 04:39
* fix: Give every action attempt wait at least one poll

The poll loop checked whether a whole polling interval would fit before
the deadline, so it gave up one full interval early. With a timeout
shorter than the interval it raised immediately, having polled zero
times and consumed none of its budget: timeout 30 with polling_interval
60 never made a single request.

Check the deadline itself instead, and cap the sleep at the time left.
A pending attempt is now always polled at least once, and the wait no
longer overruns the deadline by up to an interval.

Also validate the options up front. A polling_interval of 0 would poll
without pause, and a negative one reached usleep() and raised a bare
ValueError; both now raise InvalidOptionsError, as does a negative
timeout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HH3wdHh4Y6Wjyc5uHwk5iG

* refactor: Drop explanatory comments

Comments that restate what the code and tests already say, or that argue a
point at a reviewer, do not earn their keep once the review is over.

---------

Co-authored-by: Claude <noreply@anthropic.com>
* fix: Accept the null sentinel on the workspaces proxy

WorkspacesProxy hand-copies the generated workspace route signatures,
and its copy of create had narrowed connect_partner_name to ?string
where the generated client declares string|NullValue|null. So
NullValue::NULL worked through Seam and raised a TypeError through
SeamWithoutWorkspace, even though the README presents the sentinel as a
general rule.

Widen the parameter to match and restore the docblock the copy dropped.

The proxy already forwards by name to survive a parameter being
reordered, but nothing guarded against a type drifting, and psalm does
not analyze src/Routes. Add a test that reflects over both signatures
and compares every parameter name, type, and default, so the next drift
fails with a diff rather than reaching a caller.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HH3wdHh4Y6Wjyc5uHwk5iG

* refactor: Drop explanatory comments

Comments that restate what the code and tests already say, or that argue a
point at a reviewer, do not earn their keep once the review is over.

---------

Co-authored-by: Claude <noreply@anthropic.com>
* fix: Stop the paginator when a cursor repeats

flatten and flattenToArray looped on has_next_page alone, with no bound
on the number of pages and no memory of the cursors already fetched. A
server or cache that handed back a cursor it had already given out sent
the walk around forever, refetching the same page while flattenToArray
grew the result array until the process ran out of memory. Nothing
stopped it: the request timeout resets on every page.

Walk the pages through one generator that remembers the cursors it has
followed and ends when one comes back, so both flatten and
flattenToArray terminate.

Signal the first page with a null cursor rather than the reserved string
"FIRST_PAGE". A real cursor equal to that literal used to pass the
next-page checks and then be dropped, silently refetching page one.

Keep the pagination of the page in flight in a single field instead of a
map keyed by cursor that was written once, read once, and never cleared.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HH3wdHh4Y6Wjyc5uHwk5iG

* refactor: Drop explanatory comments

Comments that restate what the code and tests already say, or that argue a
point at a reviewer, do not earn their keep once the review is over.

---------

Co-authored-by: Claude <noreply@anthropic.com>
* fix: Format search param floats without touching global ini state

shortest_digits flipped serialize_precision to -1 around every float it
formatted, then restored it. The window is small but it is process wide:
anything else that formats a float inside it, a fiber or coroutine that
runs there, a tick function, a signal handler calling json_encode, sees
the altered setting. The comment on the call already named the hazard it
only partly avoids.

Widen a printf conversion until the result reads back as the same float
instead. That is what "shortest that round-trips" means, it depends on
no configuration, and it is safe to re-enter. Output is unchanged; the
existing float vectors cover it.

Also fix the expanded year. The ISO format the port targets switches to
a signed six digit year outside 0000-9999, which %04d never produced:
year 12345 came out as "12345" where the reference gives "+012345", and
year -1 came out as "-001", because the sign counted against the field
width, which is not a valid date in any format.

Both were reachable only through setDate or a large timestamp, since
parsing such a year throws, which is how they survived.

Document what StrictUrlSearchParamsSerializer::update does with the
strict flag: it follows the query rather than the call, so params
already present count. That matches the empty query rule and is left
as is; only the docblock was silent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HH3wdHh4Y6Wjyc5uHwk5iG

* refactor: Drop explanatory comments

Comments that restate what the code and tests already say, or that argue a
point at a reviewer, do not earn their keep once the review is over.

---------

Co-authored-by: Claude <noreply@anthropic.com>
* fix: Stop the search params map form from corrupting values

The map form of the UrlSearchParams constructor cast every value with
(string). An array under a string key became the literal "Array" behind
an "Array to string conversion" warning, so the documented shape for a
repeated param silently dropped its contents. Booleans became "1" and
"", where the serialization standard renders "true" and "false", leaving
two encodings of a bool in one package. A float or a date got PHP's
default formatting rather than the ECMAScript number and ISO formats the
standard requires, and an object raised a bare Error.

Expand a list into one pair per element, which is how the standard
represents an array and what the class docblock already describes.
Render the values this class can render the way the serializer renders
them, and refuse the ones it cannot with UnserializableParamError,
pointing at UrlSearchParamsSerializer, where that formatting lives.

Also fix the README pagination resume example, which decoded the stored
state with json_decode(..., false) and handed the resulting stdClass to
createPaginator's array parameter, raising a TypeError as written. The
object form is load bearing for the pagination half of the snippet, so
cast the params half rather than decoding associatively.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HH3wdHh4Y6Wjyc5uHwk5iG

* refactor: Drop explanatory comments

Comments that restate what the code and tests already say, or that argue a
point at a reviewer, do not earn their keep once the review is over.

---------

Co-authored-by: Claude <noreply@anthropic.com>
Resource constructor parameters were ordered by property name alone, so
an optional property, one carrying a null default, could be declared
ahead of a required one. PHP deprecates that ordering, which made every
optional default inert, and PHP 9 is expected to make it fatal.

Loading the resource classmap emitted 409 E_DEPRECATED notices across 26
of the 32 files. None of the tooling saw them: psalm and phpunit both
exclude src/Resources, and phpunit routes deprecations to a channel
failOnWarning does not cover, so the suite stayed green through all of
them. Nor is it only cosmetic. composer lint:syntax shells out to php
-l, so it fails on any host whose error_reporting includes E_DEPRECATED,
which is PHP's own default when no php.ini is loaded.

Emit required properties first and optional ones after, alphabetical
within each group, the same shape the route generator already produces
for endpoint parameters. Which properties are required stays the
blueprint's call, read from isOptional, rather than being flattened by
giving everything a default. from_json passes every value by name, so it
is unaffected.

Turn on failOnDeprecation so this cannot come back unnoticed.


Claude-Session: https://claude.ai/code/session_01HH3wdHh4Y6Wjyc5uHwk5iG

Co-authored-by: Claude <noreply@anthropic.com>
The resolver polled /action_attempts/get with the id in a JSON body on a
GET, while the generated route for the same endpoint sends it as a
query. A GET body is not carried reliably: any proxy, CDN, or load
balancer that strips one breaks every wait loop, and it breaks it after
the write has already been commanded.

The body also skipped the serializing client's query handling, so
_strict=true was never applied to the poll.

Send a query instead, matching the generated route. The fake server
reads GET bodies, so the wire shape is asserted with the recording
client.


Claude-Session: https://claude.ai/code/session_01HH3wdHh4Y6Wjyc5uHwk5iG

Co-authored-by: Claude <noreply@anthropic.com>
A client passed with the client option is used exactly as given, which
means it does not carry the SDK's error mapping or retries: an API error
raises Guzzle's exception rather than HttpApiError, and a retryable
failure is not retried. Nothing said so, and the README's own
from_client example is subject to it.

Leave that behavior alone and make it opt in instead. Guzzle fixes the
handler stack when a client is constructed, so the middleware cannot be
added afterwards; ClientFactory::add_middleware puts it on a stack the
caller builds the client with. ClientFactory::create now uses the same
method, so there is one definition of the order the middleware goes on
in.

Applying it twice would stack two sets of retries, so it is documented
as once per stack rather than guarded, since the caller owns the stack.

Also correct the claim that $seam->client is the Guzzle client. It is a
SerializingClient implementing ClientInterface, so Guzzle specific calls
on it fail.


Claude-Session: https://claude.ai/code/session_01HH3wdHh4Y6Wjyc5uHwk5iG

Co-authored-by: Claude <noreply@anthropic.com>
Generated methods read the resource straight off the decoded envelope,
with no guard on any of the unwrap sites. A 200 whose body had been
rewritten or truncated on the way back, by a proxy, a gateway
maintenance page carrying a JSON content type, or a load balancer, left
the read as null and the failure surfaced from the method's return type:

  TypeError: DevicesClient::get(): Return value must be of type Device,
  null returned

A list endpoint got array_map(): Argument #2 must be of type array. Both
are an Error rather than an Exception, so neither is caught by
catch (\Exception) nor by catch (SeamException), and neither says what
was wrong with the response.

Add InvalidResponseError, a SeamException, and read the envelope through
Body::read and Body::read_list, which raise it naming the endpoint and
the key. Keeping the guard in one place leaves the generated call sites
a single call rather than a block repeated at every one.

The action attempt poll read the same way and raised a bare
UnexpectedValueException, which was equally invisible to a Seam catch
block. It now raises the same error.

The malformed-response tests covered only 500s, which never reach the
unwrap. Add the malformed 200 cases: a missing key, the wrong key, a
body that is not an object, an empty body, unparseable JSON, an HTML
gateway page, a list key holding something that is not a list, and a
malformed poll response.


Claude-Session: https://claude.ai/code/session_01HH3wdHh4Y6Wjyc5uHwk5iG

Co-authored-by: Claude <noreply@anthropic.com>
SeamWebhook::verify raised Svix's WebhookVerificationException for a
payload whose signature had already matched but whose body would not
parse. The README maps that exception to a 400, so an unreadable
delivery was answered with an error and Svix redelivered it on its full
backoff schedule, while whoever read the logs saw a verification failure
and went looking for a forgery.

Raise InvalidWebhookPayloadError instead, a new SeamException, so the
two cases can be answered differently: a signature that does not match
may be forged, and a body that does not parse is genuinely from Seam and
will not become readable however many times it arrives.

Catch the parse failure explicitly rather than inferring it from a null
event, and treat a correctly signed payload that is not an event the
same way. It used to be returned as an Event with every field null, with
nothing to tell the caller.

Cast header names before lowercasing them, since an all-digit name
arrives as an int key and would raise a TypeError.

The parse path had no coverage at all. Add cases for malformed JSON, a
non-object body, an empty body, and a signed non-event, along with the
expired-timestamp and missing-header cases the suite was missing.


Claude-Session: https://claude.ai/code/session_01HH3wdHh4Y6Wjyc5uHwk5iG

Co-authored-by: Claude <noreply@anthropic.com>
…474)

The generated guard counted limit and page_cursor as parameters, so
access_codes->list(limit: 20) satisfied it while naming no filter, and a
paginator satisfied it from page 2 onward purely because page_cursor was
set.

Co-authored-by: Claude <noreply@anthropic.com>
* feat: restore discriminated resource variants

* fix: preserve unknown response values

* fix: keep response enum values as strings

* docs: explain optional response enums
@razor-x razor-x changed the title v4 SDK v4 Aug 20, 2026
@razor-x
razor-x marked this pull request as ready for review August 20, 2026 22:28
@razor-x
razor-x merged commit 4ff1377 into main Aug 20, 2026
18 checks passed
@razor-x
razor-x deleted the beta branch August 20, 2026 22:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants