Skip to content

Commit d3bb116

Browse files
feat(api): webhook and deep research support
1 parent ff8c556 commit d3bb116

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

50 files changed

+1767
-392
lines changed

.github/workflows/detect-breaking-changes.yml

Lines changed: 0 additions & 35 deletions
This file was deleted.

.stats.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
configured_endpoints: 111
2-
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/openai%2Fopenai-ef4ecb19eb61e24c49d77fef769ee243e5279bc0bdbaee8d0f8dba4da8722559.yml
3-
openapi_spec_hash: 1b8a9767c9f04e6865b06c41948cdc24
4-
config_hash: cae2d1f187b5b9f8dfa00daa807da42a
2+
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/openai%2Fopenai-cca460eaf5cc13e9d6e5293eb97aac53d66dc1385c691f74b768c97d165b6e8b.yml
3+
openapi_spec_hash: 9ec43d443b3dd58ca5aa87eb0a7eb49f
4+
config_hash: e74d6791681e3af1b548748ff47a22c2

README.md

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,84 @@ client.files.create(
406406

407407
The async client uses the exact same interface. If you pass a [`PathLike`](https://docs.python.org/3/library/os.html#os.PathLike) instance, the file contents will be read asynchronously automatically.
408408

409+
## Webhook Verification
410+
411+
Verifying webhook signatures is _optional but encouraged_.
412+
413+
### Parsing webhook payloads
414+
415+
For most use cases, you will likely want to verify the webhook and parse the payload at the same time. To achieve this, we provide the method `client.webhooks.unwrap()`, which parses a webhook request and verifies that it was sent by OpenAI. This method will raise an error if the signature is invalid.
416+
417+
Note that the `body` parameter must be the raw JSON string sent from the server (do not parse it first). The `.unwrap()` method will parse this JSON for you into an event object after verifying the webhook was sent from OpenAI.
418+
419+
```python
420+
from openai import OpenAI
421+
from flask import Flask, request
422+
423+
app = Flask(__name__)
424+
client = OpenAI() # OPENAI_WEBHOOK_SECRET environment variable is used by default
425+
426+
427+
@app.route("/webhook", methods=["POST"])
428+
def webhook():
429+
request_body = request.get_data(as_text=True)
430+
431+
try:
432+
event = client.webhooks.unwrap(request_body, request.headers)
433+
434+
if event.type == "response.completed":
435+
print("Response completed:", event.data)
436+
elif event.type == "response.failed":
437+
print("Response failed:", event.data)
438+
else:
439+
print("Unhandled event type:", event.type)
440+
441+
return "ok"
442+
except Exception as e:
443+
print("Invalid signature:", e)
444+
return "Invalid signature", 400
445+
446+
447+
if __name__ == "__main__":
448+
app.run(port=8000)
449+
```
450+
451+
### Verifying webhook payloads directly
452+
453+
In some cases, you may want to verify the webhook separately from parsing the payload. If you prefer to handle these steps separately, we provide the method `client.webhooks.verify_signature()` to _only verify_ the signature of a webhook request. Like `.unwrap()`, this method will raise an error if the signature is invalid.
454+
455+
Note that the `body` parameter must be the raw JSON string sent from the server (do not parse it first). You will then need to parse the body after verifying the signature.
456+
457+
```python
458+
import json
459+
from openai import OpenAI
460+
from flask import Flask, request
461+
462+
app = Flask(__name__)
463+
client = OpenAI() # OPENAI_WEBHOOK_SECRET environment variable is used by default
464+
465+
466+
@app.route("/webhook", methods=["POST"])
467+
def webhook():
468+
request_body = request.get_data(as_text=True)
469+
470+
try:
471+
client.webhooks.verify_signature(request_body, request.headers)
472+
473+
# Parse the body after verification
474+
event = json.loads(request_body)
475+
print("Verified event:", event)
476+
477+
return "ok"
478+
except Exception as e:
479+
print("Invalid signature:", e)
480+
return "Invalid signature", 400
481+
482+
483+
if __name__ == "__main__":
484+
app.run(port=8000)
485+
```
486+
409487
## Handling errors
410488

411489
When the library is unable to connect to the API (for example, due to network connection problems or a timeout), a subclass of `openai.APIConnectionError` is raised.

api.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,35 @@ Methods:
395395
- <code>client.vector_stores.file_batches.<a href="./src/openai/resources/vector_stores/file_batches.py">poll</a>(\*args) -> VectorStoreFileBatch</code>
396396
- <code>client.vector_stores.file_batches.<a href="./src/openai/resources/vector_stores/file_batches.py">upload_and_poll</a>(\*args) -> VectorStoreFileBatch</code>
397397

398+
# Webhooks
399+
400+
Types:
401+
402+
```python
403+
from openai.types.webhooks import (
404+
BatchCancelledWebhookEvent,
405+
BatchCompletedWebhookEvent,
406+
BatchExpiredWebhookEvent,
407+
BatchFailedWebhookEvent,
408+
EvalRunCanceledWebhookEvent,
409+
EvalRunFailedWebhookEvent,
410+
EvalRunSucceededWebhookEvent,
411+
FineTuningJobCancelledWebhookEvent,
412+
FineTuningJobFailedWebhookEvent,
413+
FineTuningJobSucceededWebhookEvent,
414+
ResponseCancelledWebhookEvent,
415+
ResponseCompletedWebhookEvent,
416+
ResponseFailedWebhookEvent,
417+
ResponseIncompleteWebhookEvent,
418+
UnwrapWebhookEvent,
419+
)
420+
```
421+
422+
Methods:
423+
424+
- <code>client.webhooks.<a href="./src/openai/resources/webhooks.py">unwrap</a>(payload, headers, \*, secret) -> UnwrapWebhookEvent</code>
425+
- <code>client.webhooks.<a href="./src/openai/resources/webhooks.py">verify_signature</a>(payload, headers, \*, secret, tolerance) -> None</code>
426+
398427
# Beta
399428

400429
## Realtime
@@ -774,6 +803,7 @@ from openai.types.responses import (
774803
ResponseWebSearchCallSearchingEvent,
775804
Tool,
776805
ToolChoiceFunction,
806+
ToolChoiceMcp,
777807
ToolChoiceOptions,
778808
ToolChoiceTypes,
779809
WebSearchTool,

scripts/detect-breaking-changes

Lines changed: 0 additions & 24 deletions
This file was deleted.

src/openai/__init__.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
LengthFinishReasonError,
3131
UnprocessableEntityError,
3232
APIResponseValidationError,
33+
InvalidWebhookSignatureError,
3334
ContentFilterFinishReasonError,
3435
)
3536
from ._base_client import DefaultHttpxClient, DefaultAioHttpClient, DefaultAsyncHttpxClient
@@ -62,6 +63,7 @@
6263
"InternalServerError",
6364
"LengthFinishReasonError",
6465
"ContentFilterFinishReasonError",
66+
"InvalidWebhookSignatureError",
6567
"Timeout",
6668
"RequestOptions",
6769
"Client",
@@ -121,6 +123,8 @@
121123

122124
project: str | None = None
123125

126+
webhook_secret: str | None = None
127+
124128
base_url: str | _httpx.URL | None = None
125129

126130
timeout: float | Timeout | None = DEFAULT_TIMEOUT
@@ -183,6 +187,17 @@ def project(self, value: str | None) -> None: # type: ignore
183187

184188
project = value
185189

190+
@property # type: ignore
191+
@override
192+
def webhook_secret(self) -> str | None:
193+
return webhook_secret
194+
195+
@webhook_secret.setter # type: ignore
196+
def webhook_secret(self, value: str | None) -> None: # type: ignore
197+
global webhook_secret
198+
199+
webhook_secret = value
200+
186201
@property
187202
@override
188203
def base_url(self) -> _httpx.URL:
@@ -335,6 +350,7 @@ def _load_client() -> OpenAI: # type: ignore[reportUnusedFunction]
335350
api_key=api_key,
336351
organization=organization,
337352
project=project,
353+
webhook_secret=webhook_secret,
338354
base_url=base_url,
339355
timeout=timeout,
340356
max_retries=max_retries,
@@ -363,6 +379,7 @@ def _reset_client() -> None: # type: ignore[reportUnusedFunction]
363379
models as models,
364380
batches as batches,
365381
uploads as uploads,
382+
webhooks as webhooks,
366383
responses as responses,
367384
containers as containers,
368385
embeddings as embeddings,

src/openai/_client.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
from .resources.images import Images, AsyncImages
5858
from .resources.models import Models, AsyncModels
5959
from .resources.batches import Batches, AsyncBatches
60+
from .resources.webhooks import Webhooks, AsyncWebhooks
6061
from .resources.beta.beta import Beta, AsyncBeta
6162
from .resources.chat.chat import Chat, AsyncChat
6263
from .resources.embeddings import Embeddings, AsyncEmbeddings
@@ -78,6 +79,7 @@ class OpenAI(SyncAPIClient):
7879
api_key: str
7980
organization: str | None
8081
project: str | None
82+
webhook_secret: str | None
8183

8284
websocket_base_url: str | httpx.URL | None
8385
"""Base URL for WebSocket connections.
@@ -93,6 +95,7 @@ def __init__(
9395
api_key: str | None = None,
9496
organization: str | None = None,
9597
project: str | None = None,
98+
webhook_secret: str | None = None,
9699
base_url: str | httpx.URL | None = None,
97100
websocket_base_url: str | httpx.URL | None = None,
98101
timeout: Union[float, Timeout, None, NotGiven] = NOT_GIVEN,
@@ -119,6 +122,7 @@ def __init__(
119122
- `api_key` from `OPENAI_API_KEY`
120123
- `organization` from `OPENAI_ORG_ID`
121124
- `project` from `OPENAI_PROJECT_ID`
125+
- `webhook_secret` from `OPENAI_WEBHOOK_SECRET`
122126
"""
123127
if api_key is None:
124128
api_key = os.environ.get("OPENAI_API_KEY")
@@ -136,6 +140,10 @@ def __init__(
136140
project = os.environ.get("OPENAI_PROJECT_ID")
137141
self.project = project
138142

143+
if webhook_secret is None:
144+
webhook_secret = os.environ.get("OPENAI_WEBHOOK_SECRET")
145+
self.webhook_secret = webhook_secret
146+
139147
self.websocket_base_url = websocket_base_url
140148

141149
if base_url is None:
@@ -216,6 +224,12 @@ def vector_stores(self) -> VectorStores:
216224

217225
return VectorStores(self)
218226

227+
@cached_property
228+
def webhooks(self) -> Webhooks:
229+
from .resources.webhooks import Webhooks
230+
231+
return Webhooks(self)
232+
219233
@cached_property
220234
def beta(self) -> Beta:
221235
from .resources.beta import Beta
@@ -288,6 +302,7 @@ def copy(
288302
api_key: str | None = None,
289303
organization: str | None = None,
290304
project: str | None = None,
305+
webhook_secret: str | None = None,
291306
websocket_base_url: str | httpx.URL | None = None,
292307
base_url: str | httpx.URL | None = None,
293308
timeout: float | Timeout | None | NotGiven = NOT_GIVEN,
@@ -325,6 +340,7 @@ def copy(
325340
api_key=api_key or self.api_key,
326341
organization=organization or self.organization,
327342
project=project or self.project,
343+
webhook_secret=webhook_secret or self.webhook_secret,
328344
websocket_base_url=websocket_base_url or self.websocket_base_url,
329345
base_url=base_url or self.base_url,
330346
timeout=self.timeout if isinstance(timeout, NotGiven) else timeout,
@@ -379,6 +395,7 @@ class AsyncOpenAI(AsyncAPIClient):
379395
api_key: str
380396
organization: str | None
381397
project: str | None
398+
webhook_secret: str | None
382399

383400
websocket_base_url: str | httpx.URL | None
384401
"""Base URL for WebSocket connections.
@@ -394,6 +411,7 @@ def __init__(
394411
api_key: str | None = None,
395412
organization: str | None = None,
396413
project: str | None = None,
414+
webhook_secret: str | None = None,
397415
base_url: str | httpx.URL | None = None,
398416
websocket_base_url: str | httpx.URL | None = None,
399417
timeout: Union[float, Timeout, None, NotGiven] = NOT_GIVEN,
@@ -420,6 +438,7 @@ def __init__(
420438
- `api_key` from `OPENAI_API_KEY`
421439
- `organization` from `OPENAI_ORG_ID`
422440
- `project` from `OPENAI_PROJECT_ID`
441+
- `webhook_secret` from `OPENAI_WEBHOOK_SECRET`
423442
"""
424443
if api_key is None:
425444
api_key = os.environ.get("OPENAI_API_KEY")
@@ -437,6 +456,10 @@ def __init__(
437456
project = os.environ.get("OPENAI_PROJECT_ID")
438457
self.project = project
439458

459+
if webhook_secret is None:
460+
webhook_secret = os.environ.get("OPENAI_WEBHOOK_SECRET")
461+
self.webhook_secret = webhook_secret
462+
440463
self.websocket_base_url = websocket_base_url
441464

442465
if base_url is None:
@@ -517,6 +540,12 @@ def vector_stores(self) -> AsyncVectorStores:
517540

518541
return AsyncVectorStores(self)
519542

543+
@cached_property
544+
def webhooks(self) -> AsyncWebhooks:
545+
from .resources.webhooks import AsyncWebhooks
546+
547+
return AsyncWebhooks(self)
548+
520549
@cached_property
521550
def beta(self) -> AsyncBeta:
522551
from .resources.beta import AsyncBeta
@@ -589,6 +618,7 @@ def copy(
589618
api_key: str | None = None,
590619
organization: str | None = None,
591620
project: str | None = None,
621+
webhook_secret: str | None = None,
592622
websocket_base_url: str | httpx.URL | None = None,
593623
base_url: str | httpx.URL | None = None,
594624
timeout: float | Timeout | None | NotGiven = NOT_GIVEN,
@@ -626,6 +656,7 @@ def copy(
626656
api_key=api_key or self.api_key,
627657
organization=organization or self.organization,
628658
project=project or self.project,
659+
webhook_secret=webhook_secret or self.webhook_secret,
629660
websocket_base_url=websocket_base_url or self.websocket_base_url,
630661
base_url=base_url or self.base_url,
631662
timeout=self.timeout if isinstance(timeout, NotGiven) else timeout,

0 commit comments

Comments
 (0)