From 925f68fbb61f074912131613a373b79e25e177e0 Mon Sep 17 00:00:00 2001 From: Moritz Mazetti Date: Thu, 11 Dec 2025 15:20:57 +0100 Subject: [PATCH 1/3] feat(webhook): implement webhook handling with configuration options --- README.md | 41 +++ composer.json | 4 +- config/openai.php | 34 ++ phpunit.xml.dist | 3 + routes/web.php | 14 + src/Events/WebhookReceived.php | 19 ++ src/Http/Controllers/WebhookController.php | 26 ++ .../Middleware/VerifyWebhookSignature.php | 53 ++++ src/Http/Requests/WebhookRequest.php | 60 ++++ src/ServiceProvider.php | 21 ++ tests/Arch.php | 2 + tests/ServiceProvider.php | 19 ++ tests/WebhookTestCase.php | 28 ++ tests/Webhooks.php | 298 ++++++++++++++++++ 14 files changed, 621 insertions(+), 1 deletion(-) create mode 100644 routes/web.php create mode 100644 src/Events/WebhookReceived.php create mode 100644 src/Http/Controllers/WebhookController.php create mode 100644 src/Http/Middleware/VerifyWebhookSignature.php create mode 100644 src/Http/Requests/WebhookRequest.php create mode 100644 tests/WebhookTestCase.php create mode 100644 tests/Webhooks.php diff --git a/README.md b/README.md index 41731fa..6f1ecb3 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,47 @@ OpenAI::assertSent(Responses::class, function (string $method, array $parameters For more testing examples, take a look at the [openai-php/client](https://github.com/openai-php/client#testing) repository. +## Webhooks + +You can easily handle OpenAI webhooks using the built-in webhook receiver. Start by enabling the webhook receiver: + +```env +OPENAI_WEBHOOKS_ENABLED=true +``` + +Register the webhook on your OpenAI dashboard, at https://openai.com. + +### Webhook Endpoint + +By default, the webhook endpoint is configured to `/openai/webhook`. You can customize this URL by changing the `openai.webhook.path` configuration value. + +```env +OPENAI_WEBHOOK_URI=/openai/webhook +``` + +### Webhook Signing Secret + +Then, add your webhook signing secret to your `.env` file: + +```env +OPENAI_WEBHOOK_SECRET=whsec_... +``` + +Now you're ready to handle incoming webhooks. All events received will be dispatched as Laravel events, which you can listen to in your application. + +```php +use OpenAI\Laravel\Events\WebhookReceived; +use Illuminate\Support\Facades\Event; + +Event::listen(WebhookReceived::class, function (WebhookReceived $event) { + if ($event->type === WebhookEventType::ResponseCompleted) { + $payload = $event->payload; + + // Handle the completed response... + } +}); +``` + --- OpenAI PHP for Laravel is an open-sourced software licensed under the **[MIT license](https://opensource.org/licenses/MIT)**. diff --git a/composer.json b/composer.json index a679838..a902825 100644 --- a/composer.json +++ b/composer.json @@ -13,10 +13,12 @@ "php": "^8.2.0", "guzzlehttp/guzzle": "^7.9.3", "laravel/framework": "^11.29|^12.12", - "openai-php/client": "^0.18.0" + "openai-php/client": "dev-main", + "symfony/psr-http-message-bridge": "^8.0.0" }, "require-dev": { "laravel/pint": "^1.22.0", + "orchestra/testbench": "^10.8.0", "pestphp/pest": "^3.8.2|^4.0.0", "pestphp/pest-plugin-arch": "^3.1.1|^4.0.0", "phpstan/phpstan": "^2.1", diff --git a/config/openai.php b/config/openai.php index ebf66eb..3fb9ae9 100644 --- a/config/openai.php +++ b/config/openai.php @@ -46,4 +46,38 @@ */ 'request_timeout' => env('OPENAI_REQUEST_TIMEOUT', 30), + + 'webhook' => [ + /* + |-------------------------------------------------------------------------- + | Webhook + |-------------------------------------------------------------------------- + | This option controls whether the OpenAI webhook endpoint is + | enabled. Set this to true to enable automatic handling of webhook + | requests from OpenAI. + */ + 'enabled' => env('OPENAI_WEBHOOK_ENABLED', false), + + /* + |-------------------------------------------------------------------------- + | Webhook URI + |-------------------------------------------------------------------------- + | + | This value is the URI path where OpenAI will send webhook requests to. + | You may change this path to anything you like. Make sure to update + | your OpenAI webhook settings to match this URI. + */ + 'uri' => env('OPENAI_WEBHOOK_URI', '/openai/webhook'), + + /* + |-------------------------------------------------------------------------- + | Webhook Signing secret + |-------------------------------------------------------------------------- + | + | This value is the signing secret used to verify incoming webhook + | requests from OpenAI. You can find this secret in your OpenAI + | dashboard, in the webhook settings for your application. + */ + 'secret' => env('OPENAI_WEBHOOK_SECRET'), + ], ]; diff --git a/phpunit.xml.dist b/phpunit.xml.dist index c981961..d4d6eee 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -21,6 +21,9 @@ displayDetailsOnTestsThatTriggerNotices="true" displayDetailsOnTestsThatTriggerWarnings="true" > + + + ./tests diff --git a/routes/web.php b/routes/web.php new file mode 100644 index 0000000..71f56bf --- /dev/null +++ b/routes/web.php @@ -0,0 +1,14 @@ + config('openai.webhook.middleware', ['web'])], function () { + $webhookUri = config('openai.webhook.uri', '/openai/webhook'); + assert(is_string($webhookUri)); + + Route::post($webhookUri, WebhookController::class) + ->name('webhook') + ->middleware(VerifyWebhookSignature::class); +}); diff --git a/src/Events/WebhookReceived.php b/src/Events/WebhookReceived.php new file mode 100644 index 0000000..4161acd --- /dev/null +++ b/src/Events/WebhookReceived.php @@ -0,0 +1,19 @@ + $payload + */ + public function __construct( + public WebhookEvent $type, + public string $id, + public DateTimeInterface $timestamp, + public array $payload, + ) {} +} diff --git a/src/Http/Controllers/WebhookController.php b/src/Http/Controllers/WebhookController.php new file mode 100644 index 0000000..146576c --- /dev/null +++ b/src/Http/Controllers/WebhookController.php @@ -0,0 +1,26 @@ +dispatch( + new WebhookReceived( + $request->getEventType(), + $request->getEventId(), + $request->getTimestamp(), + $request->getData(), + ), + ); + + return response()->noContent(202); + } +} diff --git a/src/Http/Middleware/VerifyWebhookSignature.php b/src/Http/Middleware/VerifyWebhookSignature.php new file mode 100644 index 0000000..8b7cf01 --- /dev/null +++ b/src/Http/Middleware/VerifyWebhookSignature.php @@ -0,0 +1,53 @@ + + */ + +declare(strict_types=1); + +namespace OpenAI\Laravel\Http\Middleware; + +use Closure; +use Illuminate\Http\Request; +use OpenAI\Exceptions\WebhookVerificationException; +use OpenAI\Webhooks\WebhookSignatureVerifier; +use RuntimeException; +use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; + +readonly class VerifyWebhookSignature +{ + public function __construct( + private WebhookSignatureVerifier $verifier, + private PsrHttpFactory $psrBridge, + ) {} + + /** + * Handle an incoming request. + * + * @throws AccessDeniedHttpException + * @throws RuntimeException + */ + public function handle(Request $request, Closure $next): mixed + { + $psrRequest = $this->psrBridge->createRequest($request); + + try { + $this->verifier->verify($psrRequest); + } catch (WebhookVerificationException $exception) { + throw new AccessDeniedHttpException( + 'Invalid webhook signature', + $exception, + ); + } + + return $next($request); + } +} diff --git a/src/Http/Requests/WebhookRequest.php b/src/Http/Requests/WebhookRequest.php new file mode 100644 index 0000000..ab007c3 --- /dev/null +++ b/src/Http/Requests/WebhookRequest.php @@ -0,0 +1,60 @@ + + */ + public function rules(): array + { + return [ + 'id' => ['required', 'string', 'starts_with:evt_'], + 'type' => ['required', 'string', Rule::enum(WebhookEvent::class)], + 'created_at' => ['required', 'integer', 'min:0'], + 'data' => ['required', 'array'], + ]; + } + + public function getEventType(): WebhookEvent + { + $type = $this->input('type'); + assert(is_string($type)); + + return WebhookEvent::from($type); + } + + public function getEventId(): string + { + $id = $this->input('id'); + assert(is_string($id)); + + return $id; + } + + public function getTimestamp(): DateTimeInterface + { + $timestamp = $this->input('created_at'); + assert(is_int($timestamp)); + + return (new DateTimeImmutable)->setTimestamp($timestamp); + } + + /** + * @return array + */ + public function getData(): array + { + $data = $this->input('data', []); + assert(is_array($data)); + + return $data; + } +} diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index b385c8f..3ee7a23 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -5,12 +5,14 @@ namespace OpenAI\Laravel; use Illuminate\Contracts\Support\DeferrableProvider; +use Illuminate\Support\Facades\Route; use Illuminate\Support\ServiceProvider as BaseServiceProvider; use OpenAI; use OpenAI\Client; use OpenAI\Contracts\ClientContract; use OpenAI\Laravel\Commands\InstallCommand; use OpenAI\Laravel\Exceptions\ApiKeyIsMissing; +use OpenAI\Webhooks\WebhookSignatureVerifier; /** * @internal @@ -50,6 +52,11 @@ public function register(): void $this->app->alias(ClientContract::class, 'openai'); $this->app->alias(ClientContract::class, Client::class); + + $this->app + ->when(WebhookSignatureVerifier::class) + ->needs('$secret') + ->give(fn () => config('openai.webhook.secret')); } /** @@ -66,6 +73,19 @@ public function boot(): void InstallCommand::class, ]); } + + $this->registerRoutes(); + } + + private function registerRoutes(): void + { + if (config('openai.webhook.enabled')) { + Route::group([ + 'namespace' => 'OpenAI\Laravel\Http\Controllers', + 'domain' => config('openai.webhook.domain'), + 'as' => 'openai.', + ], fn () => $this->loadRoutesFrom(__DIR__.'/../routes/web.php')); + } } /** @@ -76,6 +96,7 @@ public function boot(): void public function provides(): array { return [ + WebhookSignatureVerifier::class, Client::class, ClientContract::class, 'openai', diff --git a/tests/Arch.php b/tests/Arch.php index e1ca9cc..1898a8e 100644 --- a/tests/Arch.php +++ b/tests/Arch.php @@ -11,6 +11,7 @@ 'OpenAI\Contracts\ResponseContract', 'OpenAI\Laravel\Testing\OpenAIFake', 'OpenAI\Responses\StreamResponse', + 'Illuminate\Support\Facades\Route', ]); test('service providers') @@ -21,6 +22,7 @@ 'OpenAI\Laravel', 'OpenAI', 'Illuminate\Contracts\Support\DeferrableProvider', + 'Illuminate\Support\Facades\Route', // helpers... 'config', diff --git a/tests/ServiceProvider.php b/tests/ServiceProvider.php index 122058a..43697e0 100644 --- a/tests/ServiceProvider.php +++ b/tests/ServiceProvider.php @@ -5,6 +5,7 @@ use OpenAI\Contracts\ClientContract; use OpenAI\Laravel\Exceptions\ApiKeyIsMissing; use OpenAI\Laravel\ServiceProvider; +use OpenAI\Webhooks\WebhookSignatureVerifier; it('binds the client on the container', function () { $app = app(); @@ -55,8 +56,26 @@ $provides = (new ServiceProvider($app))->provides(); expect($provides)->toBe([ + WebhookSignatureVerifier::class, Client::class, ClientContract::class, 'openai', ]); }); + +it('provides the webhook secret to the signature verifier', function () { + $app = app(); + + $app->bind('config', fn () => new Repository([ + 'openai' => [ + 'api_key' => 'test', + 'webhook' => [ + 'secret' => 'whsec_abcd1234', + ], + ], + ])); + + (new ServiceProvider($app))->register(); + + expect($app->get(WebhookSignatureVerifier::class))->toBeInstanceOf(WebhookSignatureVerifier::class); +}); diff --git a/tests/WebhookTestCase.php b/tests/WebhookTestCase.php new file mode 100644 index 0000000..02694a0 --- /dev/null +++ b/tests/WebhookTestCase.php @@ -0,0 +1,28 @@ + + */ + +declare(strict_types=1); + +namespace Tests; + +use OpenAI\Laravel\ServiceProvider; +use Orchestra\Testbench\TestCase; + +class WebhookTestCase extends TestCase +{ + protected function getPackageProviders($app): array + { + return [ + ServiceProvider::class, + ]; + } +} diff --git a/tests/Webhooks.php b/tests/Webhooks.php new file mode 100644 index 0000000..caebad9 --- /dev/null +++ b/tests/Webhooks.php @@ -0,0 +1,298 @@ +app?->get('router'); + assert($router instanceof Router); + $this->assertFalse($router->has('openai.webhook')); + } + + /** + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + #[Test] + public function webhook_route_is_registered_when_webhook_is_enabled(): void + { + $router = $this->app?->get('router'); + assert($router instanceof Router); + $this->assertTrue($router->has('openai.webhook')); + } + + /** + * @throws ContainerExceptionInterface + * @throws JsonException + * @throws WebhookVerificationException + */ + #[Test] + public function valid_webhook_request_is_accepted(): void + { + $messageId = 'evt_test_webhook'; + $timestamp = time(); + $body = [ + 'id' => 'evt_test_webhook', + 'type' => WebhookEvent::BatchFailed, + 'created_at' => $timestamp, + 'data' => [ + 'id' => 'obj_test_webhook', + ], + ]; + $signature = $this->app?->make(WebhookSignatureVerifier::class)->sign( + $messageId, + $timestamp, + json_encode($body, JSON_THROW_ON_ERROR), + ); + + Event::fake(); + $this + ->postJson('/openai/webhook', $body, [ + 'webhook-id' => $messageId, + 'webhook-timestamp' => (string) $timestamp, + 'webhook-signature' => $signature, + ]) + ->assertAccepted(); + Event::assertDispatched(WebhookReceived::class, static fn (WebhookReceived $event) => ( + $event->id === $messageId + && $event->timestamp->getTimestamp() === $timestamp + && $event->type === WebhookEvent::BatchFailed + && $event->payload === $body['data'] + )); + } + + #[Test] + public function webhook_request_with_invalid_signature_is_rejected(): void + { + Event::fake(); + $this + ->postJson('/openai/webhook', [ + 'id' => 'evt_test_webhook', + 'object' => 'event', + 'type' => 'test.event', + 'data' => [ + 'id' => 'obj_test_webhook', + ], + ], [ + 'webhook-id' => 'evt_test_webhook', + 'webhook-timestamp' => (string) time(), + 'webhook-signature' => 'v1,invalid_signature', + ]) + ->assertForbidden(); + Event::assertNotDispatched(WebhookReceived::class); + } + + #[Test] + public function webhook_request_without_signature_header_is_rejected(): void + { + Event::fake(); + $this + ->postJson('/openai/webhook', [ + 'id' => 'evt_test_webhook', + 'object' => 'event', + 'type' => 'test.event', + 'data' => [ + 'id' => 'obj_test_webhook', + ], + ], [ + 'webhook-id' => 'evt_test_webhook', + 'webhook-timestamp' => (string) time(), + ]) + ->assertForbidden(); + Event::assertNotDispatched(WebhookReceived::class); + } + + /** + * @throws NotFoundExceptionInterface + * @throws ContainerExceptionInterface + * @throws JsonException + * @throws WebhookVerificationException + */ + #[Test] + public function webhook_request_with_missing_id_header_is_rejected(): void + { + $messageId = 'evt_test_webhook'; + $timestamp = time(); + $body = [ + 'id' => 'evt_test_webhook', + 'type' => WebhookEvent::BatchFailed, + 'created_at' => $timestamp, + 'data' => [ + 'id' => 'obj_test_webhook', + ], + ]; + $signature = $this->app?->get(WebhookSignatureVerifier::class)->sign( + $messageId, + $timestamp, + json_encode($body, JSON_THROW_ON_ERROR), + ); + + Event::fake(); + $this + ->postJson('/openai/webhook', $body, [ + 'webhook-timestamp' => (string) $timestamp, + 'webhook-signature' => $signature, + ]) + ->assertForbidden(); + Event::assertNotDispatched(WebhookReceived::class); + } + + /** + * @throws NotFoundExceptionInterface + * @throws ContainerExceptionInterface + * @throws JsonException + * @throws WebhookVerificationException + */ + #[Test] + public function webhook_request_with_missing_timestamp_header_is_rejected(): void + { + $messageId = 'evt_test_webhook'; + $timestamp = time(); + $body = [ + 'id' => 'evt_test_webhook', + 'type' => WebhookEvent::BatchFailed, + 'created_at' => $timestamp, + 'data' => [ + 'id' => 'obj_test_webhook', + ], + ]; + $signature = $this->app?->get(WebhookSignatureVerifier::class)->sign( + $messageId, + $timestamp, + json_encode($body, JSON_THROW_ON_ERROR), + ); + + Event::fake(); + $this + ->postJson('/openai/webhook', $body, [ + 'webhook-id' => $messageId, + 'webhook-signature' => $signature, + ]) + ->assertForbidden(); + Event::assertNotDispatched(WebhookReceived::class); + } + + /** + * @throws NotFoundExceptionInterface + * @throws ContainerExceptionInterface + * @throws JsonException + * @throws WebhookVerificationException + */ + #[Test] + public function webhook_request_with_old_timestamp_is_rejected(): void + { + $messageId = 'evt_test_webhook'; + $timestamp = time(); + $body = [ + 'id' => 'evt_test_webhook', + 'type' => WebhookEvent::BatchFailed, + 'created_at' => $timestamp, + 'data' => [ + 'id' => 'obj_test_webhook', + ], + ]; + $signature = $this->app?->get(WebhookSignatureVerifier::class)->sign( + $messageId, + $timestamp, + json_encode($body, JSON_THROW_ON_ERROR), + ); + + Event::fake(); + $this + ->postJson('/openai/webhook', $body, [ + 'webhook-id' => $messageId, + 'webhook-signature' => $signature, + 'webhook-timestamp' => (string) ($timestamp - 1000), + ]) + ->assertForbidden(); + Event::assertNotDispatched(WebhookReceived::class); + } + + /** + * @throws NotFoundExceptionInterface + * @throws ContainerExceptionInterface + * @throws JsonException + * @throws WebhookVerificationException + */ + #[Test] + public function webhook_request_with_future_timestamp_is_rejected(): void + { + $messageId = 'evt_test_webhook'; + $timestamp = time(); + $body = [ + 'id' => 'evt_test_webhook', + 'type' => WebhookEvent::BatchFailed, + 'created_at' => $timestamp, + 'data' => [ + 'id' => 'obj_test_webhook', + ], + ]; + $signature = $this->app?->get(WebhookSignatureVerifier::class)->sign( + $messageId, + $timestamp, + json_encode($body, JSON_THROW_ON_ERROR), + ); + + Event::fake(); + $this + ->postJson('/openai/webhook', $body, [ + 'webhook-id' => $messageId, + 'webhook-signature' => $signature, + 'webhook-timestamp' => (string) ($timestamp + 1000), + ]) + ->assertForbidden(); + Event::assertNotDispatched(WebhookReceived::class); + } + + /** + * @throws NotFoundExceptionInterface + * @throws ContainerExceptionInterface + * @throws JsonException + * @throws WebhookVerificationException + */ + #[Test] + public function webhook_request_with_invalid_payload_is_rejected(): void + { + $messageId = 'evt_test_webhook'; + $timestamp = time(); + $body = ['invalid_field' => 'invalid_value']; + $signature = $this->app?->get(WebhookSignatureVerifier::class)->sign( + $messageId, + $timestamp, + json_encode($body, JSON_THROW_ON_ERROR), + ); + + Event::fake(); + $this + ->postJson('/openai/webhook', $body, [ + 'webhook-id' => $messageId, + 'webhook-timestamp' => $timestamp, + 'webhook-signature' => $signature, + ]) + ->assertUnprocessable(); + Event::assertNotDispatched(WebhookReceived::class); + } +} From 3bd52c5faadb78ad0748abb8f5f43583b2bccd99 Mon Sep 17 00:00:00 2001 From: Moritz Mazetti Date: Thu, 11 Dec 2025 15:47:44 +0100 Subject: [PATCH 2/3] fix(webhook): add additional configuration parameters --- config/openai.php | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/config/openai.php b/config/openai.php index 3fb9ae9..a1dcf2d 100644 --- a/config/openai.php +++ b/config/openai.php @@ -60,14 +60,26 @@ /* |-------------------------------------------------------------------------- - | Webhook URI + | Webhook URI / Subdomain |-------------------------------------------------------------------------- | | This value is the URI path where OpenAI will send webhook requests to. | You may change this path to anything you like. Make sure to update | your OpenAI webhook settings to match this URI. + | If necessary, you may also specify a custom domain for the webhook route. */ 'uri' => env('OPENAI_WEBHOOK_URI', '/openai/webhook'), + 'domain' => env('OPENAI_WEBHOOK_DOMAIN'), + + /* + |-------------------------------------------------------------------------- + | Webhook Middleware + |-------------------------------------------------------------------------- + | Here you may specify the middleware that will be applied to + | the OpenAI webhook route. + | Note that the signature verification middleware is always applied. + */ + 'middleware' => env('OPENAI_WEBHOOK_MIDDLEWARE', 'web'), /* |-------------------------------------------------------------------------- From e99ae520428608b687d752787f514ec1adb2bc53 Mon Sep 17 00:00:00 2001 From: Moritz Mazetti Date: Thu, 11 Dec 2025 16:23:27 +0100 Subject: [PATCH 3/3] Remove copyright comments --- src/Http/Middleware/VerifyWebhookSignature.php | 12 ------------ tests/WebhookTestCase.php | 12 ------------ 2 files changed, 24 deletions(-) diff --git a/src/Http/Middleware/VerifyWebhookSignature.php b/src/Http/Middleware/VerifyWebhookSignature.php index 8b7cf01..1a3f31d 100644 --- a/src/Http/Middleware/VerifyWebhookSignature.php +++ b/src/Http/Middleware/VerifyWebhookSignature.php @@ -1,17 +1,5 @@ - */ - -declare(strict_types=1); - namespace OpenAI\Laravel\Http\Middleware; use Closure; diff --git a/tests/WebhookTestCase.php b/tests/WebhookTestCase.php index 02694a0..a4bbbdf 100644 --- a/tests/WebhookTestCase.php +++ b/tests/WebhookTestCase.php @@ -1,17 +1,5 @@ - */ - -declare(strict_types=1); - namespace Tests; use OpenAI\Laravel\ServiceProvider;