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..a1dcf2d 100644
--- a/config/openai.php
+++ b/config/openai.php
@@ -46,4 +46,50 @@
*/
'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 / 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'),
+
+ /*
+ |--------------------------------------------------------------------------
+ | 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..1a3f31d
--- /dev/null
+++ b/src/Http/Middleware/VerifyWebhookSignature.php
@@ -0,0 +1,41 @@
+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..a4bbbdf
--- /dev/null
+++ b/tests/WebhookTestCase.php
@@ -0,0 +1,16 @@
+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);
+ }
+}