From 89036c88d9753025d009963d09b81e76ec375b97 Mon Sep 17 00:00:00 2001 From: Sujip Thapa Date: Sat, 31 Oct 2026 12:04:24 +0545 Subject: [PATCH 1/2] fix(client): drop content-type header on bodyless delete requests webhooks->delete() sends no request body, but Client still attaches its default Content-Type: application/json header. The API treats that header as a promise of JSON content and rejects the empty body with a 400. Every other delete-verb call in the SDK already sends an explicit body object, even when empty, so none of them hit this: profiles, users, templates, contacts, profiles campaigns, voice agents, and calls participants (both remove() and removeAll()). webhooks->delete() is the one bodyless delete in the SDK today. Drop the header when no body is sent, instead of sending a body just to get past the check. --- src/Services/WebhooksRawService.php | 12 ++++-- tests/ClientTest.php | 66 +++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 4 deletions(-) diff --git a/src/Services/WebhooksRawService.php b/src/Services/WebhooksRawService.php index 9912850a9..5c14de47e 100644 --- a/src/Services/WebhooksRawService.php +++ b/src/Services/WebhooksRawService.php @@ -263,15 +263,19 @@ public function delete( $params, $requestOptions, ); + $header_params = ['xProfileID' => 'x-profile-id']; // @phpstan-ignore-next-line return.type return $this->client->request( method: 'delete', path: ['v3/webhooks/%1$s', $id], - headers: Util::array_transform_keys( - $parsed, - ['xProfileID' => 'x-profile-id'] - ), + headers: [ + ...Util::array_transform_keys( + array_intersect_key($parsed, array_flip(array_keys($header_params))), + $header_params, + ), + 'Content-Type' => null, + ], options: $options, convert: null, ); diff --git a/tests/ClientTest.php b/tests/ClientTest.php index 907a6782f..c041899db 100644 --- a/tests/ClientTest.php +++ b/tests/ClientTest.php @@ -41,4 +41,70 @@ public function testDefaultHeaders(): void $this->assertNotEmpty($sent); } } + + public function testBodylessDeleteOmitsContentType(): void + { + $transporter = new Client; + $transporter->setDefaultResponse( + Psr17FactoryDiscovery::findResponseFactory()->createResponse()->withStatus(204) + ); + + $client = new \SentDm\Client( + baseUrl: 'http://localhost', + apiKey: 'My API Key', + requestOptions: ['transporter' => $transporter], + ); + + $client->webhooks->delete('d4f5a6b7-c8d9-4e0f-a1b2-c3d4e5f6a7b8'); + + $this->assertNotFalse($requested = $transporter->getRequests()[0] ?? false); + $this->assertSame('DELETE', $requested->getMethod()); + $this->assertSame('', $requested->getHeaderLine('content-type')); + $this->assertSame('', (string) $requested->getBody()); + } + + public function testBodylessGetKeepsDefaultContentType(): void + { + $transporter = new Client; + $mockRsp = Psr17FactoryDiscovery::findResponseFactory() + ->createResponse() + ->withStatus(200) + ->withHeader('Content-Type', 'application/json') + ->withBody(Psr17FactoryDiscovery::findStreamFactory()->createStream(json_encode([], flags: Util::JSON_ENCODE_FLAGS) ?: '')) + ; + $transporter->setDefaultResponse($mockRsp); + + $client = new \SentDm\Client( + baseUrl: 'http://localhost', + apiKey: 'My API Key', + requestOptions: ['transporter' => $transporter], + ); + + $client->messages->retrieveStatus('msg_123'); + + $this->assertNotFalse($requested = $transporter->getRequests()[0] ?? false); + $this->assertSame('GET', $requested->getMethod()); + $this->assertSame('application/json', $requested->getHeaderLine('content-type')); + } + + public function testDeleteWithExplicitBodyKeepsContentType(): void + { + $transporter = new Client; + $transporter->setDefaultResponse( + Psr17FactoryDiscovery::findResponseFactory()->createResponse()->withStatus(204) + ); + + $client = new \SentDm\Client( + baseUrl: 'http://localhost', + apiKey: 'My API Key', + requestOptions: ['transporter' => $transporter], + ); + + $client->profiles->delete('770e8400-e29b-41d4-a716-446655440002'); + + $this->assertNotFalse($requested = $transporter->getRequests()[0] ?? false); + $this->assertSame('DELETE', $requested->getMethod()); + $this->assertSame('application/json', $requested->getHeaderLine('content-type')); + $this->assertSame('{}', (string) $requested->getBody()); + } } From f6daf50879b96c48036873dca3fde61f0401aeef Mon Sep 17 00:00:00 2001 From: Sujip Thapa Date: Sat, 31 Oct 2026 13:10:05 +0545 Subject: [PATCH 2/2] fix(client): drop content-type on any bodyless request, not just delete The per-method fix only covered webhooks->delete(). The real cause is in BaseClient::buildRequest(): it attaches a default Content-Type header to every request regardless of whether that request has a body, which is what made webhooks->delete() 400 in the first place. Move the fix there instead: drop Content-Type whenever a request has no body and the caller didn't set one explicitly. This covers every current and future bodyless operation, not just this one method, and matches how the TypeScript and Ruby SDKs already handle the identical case (confirmed by reading their request-building code directly). This also changes every typed GET call, which never sends a body either: GET requests no longer carry a Content-Type header. No GET request has a body to describe, so this brings GET in line with the same sibling SDKs' behavior rather than carrying a header that never meant anything on those calls. --- src/Core/BaseClient.php | 7 +++++++ src/Services/WebhooksRawService.php | 13 ++++--------- tests/ClientTest.php | 27 +++++++++++++++++++++++++-- 3 files changed, 36 insertions(+), 11 deletions(-) diff --git a/src/Core/BaseClient.php b/src/Core/BaseClient.php index 4992675c1..c3f44f8eb 100644 --- a/src/Core/BaseClient.php +++ b/src/Core/BaseClient.php @@ -146,6 +146,13 @@ protected function buildRequest( ...$idempotencyHeaders, ]; + // Generated methods always pass `body` for operations that define a request + // body, so only elide the content-type header when the operation has no body + // at all, not when an optional body param was omitted. + if (null === $body && !array_key_exists('Content-Type', $headers)) { + unset($mergedHeaders['Content-Type']); + } + $req = ['method' => strtoupper($method), 'path' => $uri, 'query' => $mergedQuery, 'headers' => $mergedHeaders, 'body' => $body]; return [$req, $options]; diff --git a/src/Services/WebhooksRawService.php b/src/Services/WebhooksRawService.php index 5c14de47e..7a407bbdc 100644 --- a/src/Services/WebhooksRawService.php +++ b/src/Services/WebhooksRawService.php @@ -263,19 +263,14 @@ public function delete( $params, $requestOptions, ); - $header_params = ['xProfileID' => 'x-profile-id']; - // @phpstan-ignore-next-line return.type return $this->client->request( method: 'delete', path: ['v3/webhooks/%1$s', $id], - headers: [ - ...Util::array_transform_keys( - array_intersect_key($parsed, array_flip(array_keys($header_params))), - $header_params, - ), - 'Content-Type' => null, - ], + headers: Util::array_transform_keys( + $parsed, + ['xProfileID' => 'x-profile-id'] + ), options: $options, convert: null, ); diff --git a/tests/ClientTest.php b/tests/ClientTest.php index c041899db..12ad023f9 100644 --- a/tests/ClientTest.php +++ b/tests/ClientTest.php @@ -63,7 +63,7 @@ public function testBodylessDeleteOmitsContentType(): void $this->assertSame('', (string) $requested->getBody()); } - public function testBodylessGetKeepsDefaultContentType(): void + public function testBodylessGetOmitsContentType(): void { $transporter = new Client; $mockRsp = Psr17FactoryDiscovery::findResponseFactory() @@ -84,7 +84,7 @@ public function testBodylessGetKeepsDefaultContentType(): void $this->assertNotFalse($requested = $transporter->getRequests()[0] ?? false); $this->assertSame('GET', $requested->getMethod()); - $this->assertSame('application/json', $requested->getHeaderLine('content-type')); + $this->assertSame('', $requested->getHeaderLine('content-type')); } public function testDeleteWithExplicitBodyKeepsContentType(): void @@ -107,4 +107,27 @@ public function testDeleteWithExplicitBodyKeepsContentType(): void $this->assertSame('application/json', $requested->getHeaderLine('content-type')); $this->assertSame('{}', (string) $requested->getBody()); } + + public function testBodylessRequestKeepsAnExplicitContentTypeOverride(): void + { + $transporter = new Client; + $transporter->setDefaultResponse( + Psr17FactoryDiscovery::findResponseFactory()->createResponse()->withStatus(204) + ); + + $client = new \SentDm\Client( + baseUrl: 'http://localhost', + apiKey: 'My API Key', + requestOptions: ['transporter' => $transporter], + ); + + $client->request( + method: 'delete', + path: 'v3/webhooks/d4f5a6b7-c8d9-4e0f-a1b2-c3d4e5f6a7b8', + headers: ['Content-Type' => 'application/json'], + ); + + $this->assertNotFalse($requested = $transporter->getRequests()[0] ?? false); + $this->assertSame('application/json', $requested->getHeaderLine('content-type')); + } }