Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ If you or your business relies on this package, it's important to support the de
- [Meta Information](#meta-information)
- [Troubleshooting](#troubleshooting)
- [Testing](#testing)
- [Webhooks][#webhooks]
- [Services](#services)
- [Azure](#azure)

Expand Down Expand Up @@ -3114,6 +3115,25 @@ $completion = $client->completions()->create([
]);
```

## Webhooks

The package includes a signature verifier for OpenAI webhooks. To verify the signature of incoming webhook requests, you can use the `OpenAI\Webhooks\SignatureVerifier` class.

```php
use OpenAI\Webhooks\SignatureVerifier;
use OpenAI\Exceptions\WebhookVerificationException;

$verifier = new SignatureVerifier('whsec_{your-webhook-signing-secret}');

try {
$verifier->verify($incomingRequest);

// The request is verified
} catch (WebhookVerificationException $exception) {
// The request could not be verified
}
```

## Services

### Azure
Expand Down
24 changes: 24 additions & 0 deletions src/Enums/Webhooks/WebhookEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

namespace OpenAI\Enums\Webhooks;

enum WebhookEvent: string
{
case BatchCancelled = 'batch.cancelled';
case BatchCompleted = 'batch.completed';
case BatchExpired = 'batch.expired';
case BatchFailed = 'batch.failed';
case EvalRunCancelled = 'eval.run.canceled';
case EvalRunFailed = 'eval.run.failed';
case EvalRunSucceeded = 'eval.run.succeeded';
case FineTuningJobCancelled = 'fine_tuning.job.cancelled';
case FineTuningJobFailed = 'fine_tuning.job.failed';
case FineTuningJobSucceeded = 'fine_tuning.job.succeeded';
case RealtimeCallIncoming = 'realtime.call.incoming';
case ResponseCancelled = 'response.cancelled';
case ResponseCompleted = 'response.completed';
case ResponseFailed = 'response.failed';
case ResponseIncomplete = 'response.incomplete';
case VideoCompleted = 'video.completed';
case VideoFailed = 'video.failed';
}
33 changes: 33 additions & 0 deletions src/Exceptions/WebhookVerificationException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

namespace OpenAI\Exceptions;

use RuntimeException;

class WebhookVerificationException extends RuntimeException
{
protected function __construct(string $message, int $code = 0)
{
parent::__construct('Failed to verify webhook: '.$message, $code);
}

public static function missingRequiredHeader(): self
{
return new self('Missing required header', 100);
}

public static function noMatchingSignature(): self
{
return new self('No matching signature found', 200);
}

public static function invalidTimestamp(): self
{
return new self('Invalid timestamp', 300);
}

public static function timestampMismatch(): self
{
return new self('Message timestamp outside tolerance window', 301);
}
}
124 changes: 124 additions & 0 deletions src/Webhooks/WebhookSignatureVerifier.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
<?php

namespace OpenAI\Webhooks;

use DateTimeInterface;
use OpenAI\Exceptions\WebhookVerificationException;
use Psr\Http\Message\RequestInterface;
use RuntimeException;
use UnexpectedValueException;

readonly class WebhookSignatureVerifier
{
private string $secret;

/**
* @throws UnexpectedValueException
*/
public function __construct(
string $secret,
private int $tolerance = 300,
string $secretPrefix = 'whsec_',
) {
if (str_starts_with($secret, $secretPrefix)) {
$secret = substr($secret, strlen($secretPrefix));
}

$this->secret = base64_decode($secret, true)
?: throw new UnexpectedValueException('Invalid secret format');
}

/**
* @throws WebhookVerificationException|RuntimeException
*/
public function verify(RequestInterface $request): void
{
$body = $request->getBody();
$payload = $body->getContents();
$body->rewind();

$this->verifySignature($payload, [
'webhook-id' => trim($request->getHeaderLine('webhook-id')) ?: null,
'webhook-timestamp' => trim($request->getHeaderLine('webhook-timestamp')) ?: null,
'webhook-signature' => trim($request->getHeaderLine('webhook-signature')) ?: null,
]);
}

/**
* @param array{webhook-id: ?non-falsy-string, webhook-timestamp: ?non-falsy-string, webhook-signature: ?non-falsy-string} $headers
*
* @throws WebhookVerificationException
*/
final protected function verifySignature(string $payload, array $headers): void
{
if (! isset($headers['webhook-id'], $headers['webhook-timestamp'], $headers['webhook-signature'])) {
throw WebhookVerificationException::missingRequiredHeader();
}

[
'webhook-id' => $messageId,
'webhook-timestamp' => $messageTimestamp,
'webhook-signature' => $messageSignature,
] = $headers;
$timestamp = $this->verifyTimestamp($messageTimestamp);
$signature = $this->sign($messageId, $timestamp, $payload);
[, $expectedSignature] = explode(',', $signature, 2);
$passedSignatures = explode(' ', $messageSignature);

foreach ($passedSignatures as $versionedSignature) {
[$version, $passedSignature] = explode(',', $versionedSignature, 2);

if (strcmp($version, 'v1') !== 0) {
continue;
}

if (hash_equals($expectedSignature, $passedSignature)) {
return;
}
}

throw WebhookVerificationException::noMatchingSignature();
}

/**
* @throws WebhookVerificationException
*
* @internal
*/
final public function sign(string $messageId, DateTimeInterface|int $timestamp, string $payload): string
{
$timestamp = match (true) {
$timestamp instanceof DateTimeInterface => $timestamp->getTimestamp(),
is_int($timestamp) && $timestamp > 0 => $timestamp,
default => throw WebhookVerificationException::invalidTimestamp(),
};

$hash = hash_hmac(
'sha256',
implode('.', [$messageId, $timestamp, $payload]),
$this->secret,
);
$signature = base64_encode(pack('H*', $hash));

return 'v1,'.$signature;
}

/**
* @throws WebhookVerificationException
*/
protected function verifyTimestamp(string $timestampHeader): int
{
$now = time();
$timestamp = (int) $timestampHeader;

if ($timestamp < ($now - $this->tolerance)) {
throw WebhookVerificationException::timestampMismatch();
}

if ($timestamp > ($now + $this->tolerance)) {
throw WebhookVerificationException::timestampMismatch();
}

return $timestamp;
}
}
Loading