From 9bf293a26c8c0c712133ad8d8cabf0376b810bf5 Mon Sep 17 00:00:00 2001 From: Himanshu Neema Date: Thu, 6 Nov 2025 12:30:35 -0800 Subject: [PATCH 1/9] types for webhook; traits for events --- async-openai/src/embedding.rs | 1 + async-openai/src/traits.rs | 12 + async-openai/src/types/mod.rs | 1 + async-openai/src/types/webhooks.rs | 591 +++++++++++++++++++++++++++++ 4 files changed, 605 insertions(+) create mode 100644 async-openai/src/types/webhooks.rs diff --git a/async-openai/src/embedding.rs b/async-openai/src/embedding.rs index a811600a..f5759296 100644 --- a/async-openai/src/embedding.rs +++ b/async-openai/src/embedding.rs @@ -64,6 +64,7 @@ impl<'c, C: Config> Embeddings<'c, C> { #[cfg(test)] mod tests { + use crate::error::OpenAIError; use crate::types::{CreateEmbeddingResponse, Embedding, EncodingFormat}; use crate::{types::CreateEmbeddingRequestArgs, Client}; diff --git a/async-openai/src/traits.rs b/async-openai/src/traits.rs index 62e8ae3c..0ae7462b 100644 --- a/async-openai/src/traits.rs +++ b/async-openai/src/traits.rs @@ -5,3 +5,15 @@ pub trait AsyncTryFrom: Sized { /// Performs the conversion. fn try_from(value: T) -> impl std::future::Future> + Send; } + +/// Trait for events to get their event type string. +pub trait EventType { + /// Returns the event type string (e.g., "batch.cancelled") + fn event_type(&self) -> &'static str; +} + +/// Trait for events to get their event ID. +pub trait EventId { + /// Returns the event ID + fn event_id(&self) -> &str; +} diff --git a/async-openai/src/types/mod.rs b/async-openai/src/types/mod.rs index fd2906f4..2fe87f3a 100644 --- a/async-openai/src/types/mod.rs +++ b/async-openai/src/types/mod.rs @@ -34,6 +34,7 @@ mod upload; mod users; mod vector_store; mod video; +pub mod webhooks; pub use assistant::*; pub use assistant_stream::*; diff --git a/async-openai/src/types/webhooks.rs b/async-openai/src/types/webhooks.rs new file mode 100644 index 00000000..212488fe --- /dev/null +++ b/async-openai/src/types/webhooks.rs @@ -0,0 +1,591 @@ +use serde::{Deserialize, Serialize}; + +use crate::traits::{EventId, EventType}; + +/// Sent when a batch API request has been cancelled. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct WebhookBatchCancelled { + /// The Unix timestamp (in seconds) of when the batch API request was cancelled. + pub created_at: i64, + + /// The unique ID of the event. + pub id: String, + + /// Event data payload. + pub data: WebhookBatchData, + + /// The object of the event. Always `event`. + #[serde(skip_serializing_if = "Option::is_none")] + pub object: Option, +} + +/// Sent when a batch API request has been completed. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct WebhookBatchCompleted { + /// The Unix timestamp (in seconds) of when the batch API request was completed. + pub created_at: i64, + + /// The unique ID of the event. + pub id: String, + + /// Event data payload. + pub data: WebhookBatchData, + + /// The object of the event. Always `event`. + #[serde(skip_serializing_if = "Option::is_none")] + pub object: Option, +} + +/// Sent when a batch API request has expired. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct WebhookBatchExpired { + /// The Unix timestamp (in seconds) of when the batch API request expired. + pub created_at: i64, + + /// The unique ID of the event. + pub id: String, + + /// Event data payload. + pub data: WebhookBatchData, + + /// The object of the event. Always `event`. + #[serde(skip_serializing_if = "Option::is_none")] + pub object: Option, +} + +/// Sent when a batch API request has failed. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct WebhookBatchFailed { + /// The Unix timestamp (in seconds) of when the batch API request failed. + pub created_at: i64, + + /// The unique ID of the event. + pub id: String, + + /// Event data payload. + pub data: WebhookBatchData, + + /// The object of the event. Always `event`. + #[serde(skip_serializing_if = "Option::is_none")] + pub object: Option, +} + +/// Data payload for batch webhook events. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct WebhookBatchData { + /// The unique ID of the batch API request. + pub id: String, +} + +// EventType and EventId implementations for batch events + +impl EventType for WebhookBatchCancelled { + fn event_type(&self) -> &'static str { + "batch.cancelled" + } +} + +impl EventId for WebhookBatchCancelled { + fn event_id(&self) -> &str { + &self.id + } +} + +impl EventType for WebhookBatchCompleted { + fn event_type(&self) -> &'static str { + "batch.completed" + } +} + +impl EventId for WebhookBatchCompleted { + fn event_id(&self) -> &str { + &self.id + } +} + +impl EventType for WebhookBatchExpired { + fn event_type(&self) -> &'static str { + "batch.expired" + } +} + +impl EventId for WebhookBatchExpired { + fn event_id(&self) -> &str { + &self.id + } +} + +impl EventType for WebhookBatchFailed { + fn event_type(&self) -> &'static str { + "batch.failed" + } +} + +impl EventId for WebhookBatchFailed { + fn event_id(&self) -> &str { + &self.id + } +} + +/// Sent when an eval run has been canceled. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct WebhookEvalRunCanceled { + /// The Unix timestamp (in seconds) of when the eval run was canceled. + pub created_at: i64, + + /// The unique ID of the event. + pub id: String, + + /// Event data payload. + pub data: WebhookEvalRunData, + + /// The object of the event. Always `event`. + #[serde(skip_serializing_if = "Option::is_none")] + pub object: Option, +} + +/// Sent when an eval run has failed. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct WebhookEvalRunFailed { + /// The Unix timestamp (in seconds) of when the eval run failed. + pub created_at: i64, + + /// The unique ID of the event. + pub id: String, + + /// Event data payload. + pub data: WebhookEvalRunData, + + /// The object of the event. Always `event`. + #[serde(skip_serializing_if = "Option::is_none")] + pub object: Option, +} + +/// Sent when an eval run has succeeded. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct WebhookEvalRunSucceeded { + /// The Unix timestamp (in seconds) of when the eval run succeeded. + pub created_at: i64, + + /// The unique ID of the event. + pub id: String, + + /// Event data payload. + pub data: WebhookEvalRunData, + + /// The object of the event. Always `event`. + #[serde(skip_serializing_if = "Option::is_none")] + pub object: Option, +} + +/// Data payload for eval run webhook events. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct WebhookEvalRunData { + /// The unique ID of the eval run. + pub id: String, +} + +// EventType and EventId implementations for eval run events + +impl EventType for WebhookEvalRunCanceled { + fn event_type(&self) -> &'static str { + "eval.run.canceled" + } +} + +impl EventId for WebhookEvalRunCanceled { + fn event_id(&self) -> &str { + &self.id + } +} + +impl EventType for WebhookEvalRunFailed { + fn event_type(&self) -> &'static str { + "eval.run.failed" + } +} + +impl EventId for WebhookEvalRunFailed { + fn event_id(&self) -> &str { + &self.id + } +} + +impl EventType for WebhookEvalRunSucceeded { + fn event_type(&self) -> &'static str { + "eval.run.succeeded" + } +} + +impl EventId for WebhookEvalRunSucceeded { + fn event_id(&self) -> &str { + &self.id + } +} + +/// Sent when a fine-tuning job has been cancelled. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct WebhookFineTuningJobCancelled { + /// The Unix timestamp (in seconds) of when the fine-tuning job was cancelled. + pub created_at: i64, + + /// The unique ID of the event. + pub id: String, + + /// Event data payload. + pub data: WebhookFineTuningJobData, + + /// The object of the event. Always `event`. + #[serde(skip_serializing_if = "Option::is_none")] + pub object: Option, +} + +/// Sent when a fine-tuning job has failed. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct WebhookFineTuningJobFailed { + /// The Unix timestamp (in seconds) of when the fine-tuning job failed. + pub created_at: i64, + + /// The unique ID of the event. + pub id: String, + + /// Event data payload. + pub data: WebhookFineTuningJobData, + + /// The object of the event. Always `event`. + #[serde(skip_serializing_if = "Option::is_none")] + pub object: Option, +} + +/// Sent when a fine-tuning job has succeeded. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct WebhookFineTuningJobSucceeded { + /// The Unix timestamp (in seconds) of when the fine-tuning job succeeded. + pub created_at: i64, + + /// The unique ID of the event. + pub id: String, + + /// Event data payload. + pub data: WebhookFineTuningJobData, + + /// The object of the event. Always `event`. + #[serde(skip_serializing_if = "Option::is_none")] + pub object: Option, +} + +/// Data payload for fine-tuning job webhook events. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct WebhookFineTuningJobData { + /// The unique ID of the fine-tuning job. + pub id: String, +} + +// EventType and EventId implementations for fine-tuning job events + +impl EventType for WebhookFineTuningJobCancelled { + fn event_type(&self) -> &'static str { + "fine_tuning.job.cancelled" + } +} + +impl EventId for WebhookFineTuningJobCancelled { + fn event_id(&self) -> &str { + &self.id + } +} + +impl EventType for WebhookFineTuningJobFailed { + fn event_type(&self) -> &'static str { + "fine_tuning.job.failed" + } +} + +impl EventId for WebhookFineTuningJobFailed { + fn event_id(&self) -> &str { + &self.id + } +} + +impl EventType for WebhookFineTuningJobSucceeded { + fn event_type(&self) -> &'static str { + "fine_tuning.job.succeeded" + } +} + +impl EventId for WebhookFineTuningJobSucceeded { + fn event_id(&self) -> &str { + &self.id + } +} + +/// Sent when Realtime API receives an incoming SIP call. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct WebhookRealtimeCallIncoming { + /// The Unix timestamp (in seconds) of when the model response was completed. + pub created_at: i64, + + /// The unique ID of the event. + pub id: String, + + /// Event data payload. + pub data: WebhookRealtimeCallData, + + /// The object of the event. Always `event`. + #[serde(skip_serializing_if = "Option::is_none")] + pub object: Option, +} + +/// Data payload for realtime call webhook events. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct WebhookRealtimeCallData { + /// The unique ID of this call. + pub call_id: String, + + /// Headers from the SIP Invite. + pub sip_headers: Vec, +} + +/// A header from the SIP Invite. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct SipHeader { + /// Name of the SIP Header. + pub name: String, + + /// Value of the SIP Header. + pub value: String, +} + +// EventType and EventId implementations for realtime call events + +impl EventType for WebhookRealtimeCallIncoming { + fn event_type(&self) -> &'static str { + "realtime.call.incoming" + } +} + +impl EventId for WebhookRealtimeCallIncoming { + fn event_id(&self) -> &str { + &self.id + } +} + +/// Sent when a background response has been cancelled. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct WebhookResponseCancelled { + /// The Unix timestamp (in seconds) of when the model response was cancelled. + pub created_at: i64, + + /// The unique ID of the event. + pub id: String, + + /// Event data payload. + pub data: WebhookResponseData, + + /// The object of the event. Always `event`. + #[serde(skip_serializing_if = "Option::is_none")] + pub object: Option, +} + +/// Sent when a background response has been completed. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct WebhookResponseCompleted { + /// The Unix timestamp (in seconds) of when the model response was completed. + pub created_at: i64, + + /// The unique ID of the event. + pub id: String, + + /// Event data payload. + pub data: WebhookResponseData, + + /// The object of the event. Always `event`. + #[serde(skip_serializing_if = "Option::is_none")] + pub object: Option, +} + +/// Sent when a background response has failed. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct WebhookResponseFailed { + /// The Unix timestamp (in seconds) of when the model response failed. + pub created_at: i64, + + /// The unique ID of the event. + pub id: String, + + /// Event data payload. + pub data: WebhookResponseData, + + /// The object of the event. Always `event`. + #[serde(skip_serializing_if = "Option::is_none")] + pub object: Option, +} + +/// Sent when a background response has been interrupted. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct WebhookResponseIncomplete { + /// The Unix timestamp (in seconds) of when the model response was interrupted. + pub created_at: i64, + + /// The unique ID of the event. + pub id: String, + + /// Event data payload. + pub data: WebhookResponseData, + + /// The object of the event. Always `event`. + #[serde(skip_serializing_if = "Option::is_none")] + pub object: Option, +} + +/// Data payload for response webhook events. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct WebhookResponseData { + /// The unique ID of the model response. + pub id: String, +} + +// EventType and EventId implementations for response events + +impl EventType for WebhookResponseCancelled { + fn event_type(&self) -> &'static str { + "response.cancelled" + } +} + +impl EventId for WebhookResponseCancelled { + fn event_id(&self) -> &str { + &self.id + } +} + +impl EventType for WebhookResponseCompleted { + fn event_type(&self) -> &'static str { + "response.completed" + } +} + +impl EventId for WebhookResponseCompleted { + fn event_id(&self) -> &str { + &self.id + } +} + +impl EventType for WebhookResponseFailed { + fn event_type(&self) -> &'static str { + "response.failed" + } +} + +impl EventId for WebhookResponseFailed { + fn event_id(&self) -> &str { + &self.id + } +} + +impl EventType for WebhookResponseIncomplete { + fn event_type(&self) -> &'static str { + "response.incomplete" + } +} + +impl EventId for WebhookResponseIncomplete { + fn event_id(&self) -> &str { + &self.id + } +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(tag = "type")] +pub enum WebhookEvent { + #[serde(rename = "batch.cancelled")] + BatchCancelled(WebhookBatchCancelled), + + #[serde(rename = "batch.completed")] + BatchCompleted(WebhookBatchCompleted), + + #[serde(rename = "batch.expired")] + BatchExpired(WebhookBatchExpired), + + #[serde(rename = "batch.failed")] + BatchFailed(WebhookBatchFailed), + + #[serde(rename = "eval.run.canceled")] + EvalRunCanceled(WebhookEvalRunCanceled), + + #[serde(rename = "eval.run.failed")] + EvalRunFailed(WebhookEvalRunFailed), + + #[serde(rename = "eval.run.succeeded")] + EvalRunSucceeded(WebhookEvalRunSucceeded), + + #[serde(rename = "fine_tuning.job.cancelled")] + FineTuningJobCancelled(WebhookFineTuningJobCancelled), + + #[serde(rename = "fine_tuning.job.failed")] + FineTuningJobFailed(WebhookFineTuningJobFailed), + + #[serde(rename = "fine_tuning.job.succeeded")] + FineTuningJobSucceeded(WebhookFineTuningJobSucceeded), + + #[serde(rename = "realtime.call.incoming")] + RealtimeCallIncoming(WebhookRealtimeCallIncoming), + + #[serde(rename = "response.cancelled")] + ResponseCancelled(WebhookResponseCancelled), + + #[serde(rename = "response.completed")] + ResponseCompleted(WebhookResponseCompleted), + + #[serde(rename = "response.failed")] + ResponseFailed(WebhookResponseFailed), + + #[serde(rename = "response.incomplete")] + ResponseIncomplete(WebhookResponseIncomplete), +} + +// Trait implementations for WebhookEvent enum + +impl EventType for WebhookEvent { + fn event_type(&self) -> &'static str { + match self { + WebhookEvent::BatchCancelled(e) => e.event_type(), + WebhookEvent::BatchCompleted(e) => e.event_type(), + WebhookEvent::BatchExpired(e) => e.event_type(), + WebhookEvent::BatchFailed(e) => e.event_type(), + WebhookEvent::EvalRunCanceled(e) => e.event_type(), + WebhookEvent::EvalRunFailed(e) => e.event_type(), + WebhookEvent::EvalRunSucceeded(e) => e.event_type(), + WebhookEvent::FineTuningJobCancelled(e) => e.event_type(), + WebhookEvent::FineTuningJobFailed(e) => e.event_type(), + WebhookEvent::FineTuningJobSucceeded(e) => e.event_type(), + WebhookEvent::RealtimeCallIncoming(e) => e.event_type(), + WebhookEvent::ResponseCancelled(e) => e.event_type(), + WebhookEvent::ResponseCompleted(e) => e.event_type(), + WebhookEvent::ResponseFailed(e) => e.event_type(), + WebhookEvent::ResponseIncomplete(e) => e.event_type(), + } + } +} + +impl EventId for WebhookEvent { + fn event_id(&self) -> &str { + match self { + WebhookEvent::BatchCancelled(e) => e.event_id(), + WebhookEvent::BatchCompleted(e) => e.event_id(), + WebhookEvent::BatchExpired(e) => e.event_id(), + WebhookEvent::BatchFailed(e) => e.event_id(), + WebhookEvent::EvalRunCanceled(e) => e.event_id(), + WebhookEvent::EvalRunFailed(e) => e.event_id(), + WebhookEvent::EvalRunSucceeded(e) => e.event_id(), + WebhookEvent::FineTuningJobCancelled(e) => e.event_id(), + WebhookEvent::FineTuningJobFailed(e) => e.event_id(), + WebhookEvent::FineTuningJobSucceeded(e) => e.event_id(), + WebhookEvent::RealtimeCallIncoming(e) => e.event_id(), + WebhookEvent::ResponseCancelled(e) => e.event_id(), + WebhookEvent::ResponseCompleted(e) => e.event_id(), + WebhookEvent::ResponseFailed(e) => e.event_id(), + WebhookEvent::ResponseIncomplete(e) => e.event_id(), + } + } +} From acfea264d51f314868365bf478fbb5bcbc15ae67 Mon Sep 17 00:00:00 2001 From: Himanshu Neema Date: Thu, 6 Nov 2025 14:32:46 -0800 Subject: [PATCH 2/9] checkpoint: working webhooks --- async-openai/Cargo.toml | 3 + async-openai/src/error.rs | 11 + async-openai/src/lib.rs | 1 + async-openai/src/types/webhooks.rs | 23 ++ async-openai/src/webhooks.rs | 209 +++++++++++++++++ examples/webhooks/Cargo.toml | 12 + examples/webhooks/README.md | 48 ++++ examples/webhooks/src/main.rs | 360 +++++++++++++++++++++++++++++ 8 files changed, 667 insertions(+) create mode 100644 async-openai/src/webhooks.rs create mode 100644 examples/webhooks/Cargo.toml create mode 100644 examples/webhooks/README.md create mode 100644 examples/webhooks/src/main.rs diff --git a/async-openai/Cargo.toml b/async-openai/Cargo.toml index fecf9c8b..e6e8f266 100644 --- a/async-openai/Cargo.toml +++ b/async-openai/Cargo.toml @@ -50,6 +50,9 @@ secrecy = { version = "0.10.3", features = ["serde"] } bytes = "1.9.0" eventsource-stream = "0.2.3" tokio-tungstenite = { version = "0.26.1", optional = true, default-features = false } +hmac = "0.12.1" +sha2 = "0.10.8" +hex = "0.4.3" [dev-dependencies] tokio-test = "0.4.4" diff --git a/async-openai/src/error.rs b/async-openai/src/error.rs index 288d198b..0626fe34 100644 --- a/async-openai/src/error.rs +++ b/async-openai/src/error.rs @@ -28,6 +28,17 @@ pub enum OpenAIError { InvalidArgument(String), } +/// Errors that can occur when processing webhooks +#[derive(Debug, thiserror::Error)] +pub enum WebhookError { + /// Invalid webhook signature - verification failed + #[error("invalid webhook signature")] + InvalidSignature, + /// Failed to deserialize webhook payload + #[error("failed to deserialize webhook payload: {0}")] + Deserialization(#[from] serde_json::Error), +} + #[derive(Debug, thiserror::Error)] pub enum StreamError { /// Underlying error from reqwest_eventsource library when reading the stream diff --git a/async-openai/src/lib.rs b/async-openai/src/lib.rs index 79042328..d8bacba9 100644 --- a/async-openai/src/lib.rs +++ b/async-openai/src/lib.rs @@ -179,6 +179,7 @@ mod vector_store_file_batches; mod vector_store_files; mod vector_stores; mod video; +pub mod webhooks; pub use assistants::Assistants; pub use audio::Audio; diff --git a/async-openai/src/types/webhooks.rs b/async-openai/src/types/webhooks.rs index 212488fe..16a00534 100644 --- a/async-openai/src/types/webhooks.rs +++ b/async-openai/src/types/webhooks.rs @@ -589,3 +589,26 @@ impl EventId for WebhookEvent { } } } + +impl WebhookEvent { + /// Get the timestamp when the event was created + pub fn created_at(&self) -> i64 { + match self { + WebhookEvent::BatchCancelled(w) => w.created_at, + WebhookEvent::BatchCompleted(w) => w.created_at, + WebhookEvent::BatchExpired(w) => w.created_at, + WebhookEvent::BatchFailed(w) => w.created_at, + WebhookEvent::EvalRunCanceled(w) => w.created_at, + WebhookEvent::EvalRunFailed(w) => w.created_at, + WebhookEvent::EvalRunSucceeded(w) => w.created_at, + WebhookEvent::FineTuningJobCancelled(w) => w.created_at, + WebhookEvent::FineTuningJobFailed(w) => w.created_at, + WebhookEvent::FineTuningJobSucceeded(w) => w.created_at, + WebhookEvent::RealtimeCallIncoming(w) => w.created_at, + WebhookEvent::ResponseCancelled(w) => w.created_at, + WebhookEvent::ResponseCompleted(w) => w.created_at, + WebhookEvent::ResponseFailed(w) => w.created_at, + WebhookEvent::ResponseIncomplete(w) => w.created_at, + } + } +} diff --git a/async-openai/src/webhooks.rs b/async-openai/src/webhooks.rs new file mode 100644 index 00000000..f6ee1f3f --- /dev/null +++ b/async-openai/src/webhooks.rs @@ -0,0 +1,209 @@ +use crate::error::WebhookError; +use crate::types::webhooks::WebhookEvent; +use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; +use hmac::{Hmac, Mac}; +use sha2::Sha256; + +type HmacSha256 = Hmac; + +/// Webhook utilities for verifying and constructing webhook events +/// https://platform.openai.com/docs/guides/webhooks +pub struct Webhooks; + +impl Webhooks { + pub fn build_event( + body: &str, + signature: &str, + timestamp: &str, + webhook_id: &str, + secret: &str, + ) -> Result { + // Verify the signature + Self::verify_signature(body, signature, timestamp, webhook_id, secret)?; + + // Deserialize the event + let event: WebhookEvent = serde_json::from_str(body)?; + + Ok(event) + } + + pub fn verify_signature( + body: &str, + signature: &str, + timestamp: &str, + webhook_id: &str, + secret: &str, + ) -> Result<(), WebhookError> { + // Construct the signed payload: webhook_id.timestamp.body + let signed_payload = format!("{}.{}.{}", webhook_id, timestamp, body); + + // Remove "whsec_" prefix from secret if present + let secret_key = secret.strip_prefix("whsec_").unwrap_or(secret); + + // Decode the secret from base64 (Standard Webhooks uses base64-encoded secrets) + let secret_bytes = BASE64 + .decode(secret_key) + .map_err(|_| WebhookError::InvalidSignature)?; + + // Compute HMAC-SHA256 + let mut mac = HmacSha256::new_from_slice(&secret_bytes) + .map_err(|_| WebhookError::InvalidSignature)?; + mac.update(signed_payload.as_bytes()); + + // Get the expected signature in base64 + let expected_signature = BASE64.encode(mac.finalize().into_bytes()); + + // Parse the signature header (format: "v1,signature" or just "signature") + // Standard Webhooks uses versioned signatures + let signature_to_verify = if signature.contains(',') { + // Extract signature parts (e.g., "v1,signature1 v1,signature2") + signature + .split_whitespace() + .filter_map(|sig| { + let parts: Vec<&str> = sig.split(',').collect(); + if parts.len() == 2 && parts[0] == "v1" { + Some(parts[1]) + } else { + None + } + }) + .collect::>() + } else { + vec![signature] + }; + + // Try to match any of the provided signatures + for sig in signature_to_verify { + if constant_time_eq(sig.as_bytes(), expected_signature.as_bytes()) { + return Ok(()); + } + } + + Err(WebhookError::InvalidSignature) + } +} + +fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + + let mut result = 0u8; + for (a_byte, b_byte) in a.iter().zip(b.iter()) { + result |= a_byte ^ b_byte; + } + + result == 0 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_constant_time_eq() { + assert!(constant_time_eq(b"hello", b"hello")); + assert!(!constant_time_eq(b"hello", b"world")); + assert!(!constant_time_eq(b"hello", b"hell")); + assert!(!constant_time_eq(b"hello", b"helloo")); + } + + #[test] + fn test_verify_signature_invalid() { + let body = r#"{"test":"data"}"#; + let signature = "invalid_signature"; + let timestamp = "1234567890"; + let webhook_id = "webhook_test"; + let secret = "test_secret"; + + let result = Webhooks::verify_signature(body, signature, timestamp, webhook_id, secret); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + WebhookError::InvalidSignature + )); + } + + #[test] + fn test_verify_signature_valid() { + let body = r#"{"test":"data"}"#; + let timestamp = "1234567890"; + let webhook_id = "webhook_test"; + // Base64-encoded secret (Standard Webhooks format) + let secret = BASE64.encode(b"test_secret"); + + // Compute the expected signature + let signed_payload = format!("{}.{}.{}", webhook_id, timestamp, body); + let secret_bytes = BASE64.decode(&secret).unwrap(); + let mut mac = HmacSha256::new_from_slice(&secret_bytes).unwrap(); + mac.update(signed_payload.as_bytes()); + let signature = BASE64.encode(mac.finalize().into_bytes()); + + let result = Webhooks::verify_signature(body, &signature, timestamp, webhook_id, &secret); + assert!(result.is_ok()); + } + + #[test] + fn test_verify_signature_with_prefix() { + let body = r#"{"test":"data"}"#; + let timestamp = "1234567890"; + let webhook_id = "webhook_test"; + let secret = BASE64.encode(b"test_secret"); + let prefixed_secret = format!("whsec_{}", secret); + + // Compute signature + let signed_payload = format!("{}.{}.{}", webhook_id, timestamp, body); + let secret_bytes = BASE64.decode(&secret).unwrap(); + let mut mac = HmacSha256::new_from_slice(&secret_bytes).unwrap(); + mac.update(signed_payload.as_bytes()); + let signature = BASE64.encode(mac.finalize().into_bytes()); + + // Verify using prefixed secret + let result = + Webhooks::verify_signature(body, &signature, timestamp, webhook_id, &prefixed_secret); + assert!(result.is_ok()); + } + + #[test] + fn test_verify_signature_with_version() { + let body = r#"{"test":"data"}"#; + let timestamp = "1234567890"; + let webhook_id = "webhook_test"; + let secret = BASE64.encode(b"test_secret"); + + // Compute signature + let signed_payload = format!("{}.{}.{}", webhook_id, timestamp, body); + let secret_bytes = BASE64.decode(&secret).unwrap(); + let mut mac = HmacSha256::new_from_slice(&secret_bytes).unwrap(); + mac.update(signed_payload.as_bytes()); + let sig_b64 = BASE64.encode(mac.finalize().into_bytes()); + + // Standard Webhooks format with version prefix + let signature = format!("v1,{}", sig_b64); + + let result = Webhooks::verify_signature(body, &signature, timestamp, webhook_id, &secret); + assert!(result.is_ok()); + } + + #[test] + fn test_construct_event_invalid_json() { + let body = r#"{"invalid json"#; + let timestamp = "1234567890"; + let webhook_id = "webhook_test"; + let secret = BASE64.encode(b"test_secret"); + + // Compute valid signature for invalid JSON + let signed_payload = format!("{}.{}.{}", webhook_id, timestamp, body); + let secret_bytes = BASE64.decode(&secret).unwrap(); + let mut mac = HmacSha256::new_from_slice(&secret_bytes).unwrap(); + mac.update(signed_payload.as_bytes()); + let signature = BASE64.encode(mac.finalize().into_bytes()); + + let result = Webhooks::construct_event(body, &signature, timestamp, webhook_id, &secret); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + WebhookError::Deserialization(_) + )); + } +} diff --git a/examples/webhooks/Cargo.toml b/examples/webhooks/Cargo.toml new file mode 100644 index 00000000..2509aeeb --- /dev/null +++ b/examples/webhooks/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "webhooks" +version = "0.1.0" +edition = "2021" + +[dependencies] +async-openai = { path = "../../async-openai" } +tokio = { version = "1.42.0", features = ["full"] } +axum = "0.7.9" +tracing = "0.1.41" +tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } + diff --git a/examples/webhooks/README.md b/examples/webhooks/README.md new file mode 100644 index 00000000..692e7150 --- /dev/null +++ b/examples/webhooks/README.md @@ -0,0 +1,48 @@ +# Webhooks Example + +This example demonstrates how to handle OpenAI webhook events using the `async-openai` library, including signature verification. + + + +## Running the Example + +This example automatically: +1. Starts an Axum webhook server +2. Sends a background response request +3. Receives and displays webhook events + +### Quick Start (3 Simple Steps!) + +**Step 1: Start ngrok** (in a separate terminal) +```bash +ngrok http 3000 +``` + +You'll see output like: +``` +Forwarding https://abc123.ngrok.io -> http://localhost:3000 +``` + +**Step 2: Configure webhook in OpenAI Dashboard** + +1. Go to https://platform.openai.com/settings/organization/webhooks +2. Click "Add endpoint" +3. Enter your ngrok URL + `/webhook`: + ``` + https://abc123.ngrok.io/webhook + ``` +4. Copy the webhook secret (starts with `whsec_`) + +**Step 3: Run the example** +```bash +# Set your environment variables +export OPENAI_API_KEY="your-api-key" +export OPENAI_WEBHOOK_SECRET="whsec_your_secret_from_dashboard" + +# Run the example +cargo run --package webhooks +``` + + + + diff --git a/examples/webhooks/src/main.rs b/examples/webhooks/src/main.rs new file mode 100644 index 00000000..71770d07 --- /dev/null +++ b/examples/webhooks/src/main.rs @@ -0,0 +1,360 @@ +//! OpenAI Webhook Example with Axum +//! +//! This example demonstrates a complete webhook integration: +//! 1. Starts an Axum server with webhook endpoint +//! 2. Automatically sends a background response request +//! 3. Receives and processes webhook events +//! +//! # Prerequisites +//! +//! 1. Start ngrok in a separate terminal: +//! ```bash +//! ngrok http 3000 +//! ``` +//! +//! 2. Set your environment variables: +//! ```bash +//! export OPENAI_API_KEY="your-api-key" +//! export OPENAI_WEBHOOK_SECRET="whsec_your_secret" +//! ``` +//! +//! 3. Configure the ngrok URL in OpenAI dashboard: +//! https://platform.openai.com/settings/organization/webhooks +//! +//! 4. Run this example: +//! ```bash +//! cargo run --package webhooks +//! ``` +//! +//! The example will automatically send a background response request +//! and you'll see the webhook events being received! + +use async_openai::traits::{EventId, EventType}; +use async_openai::types::responses::{ + CreateResponseArgs, EasyInputContent, EasyInputMessage, InputItem, InputParam, MessageType, + Role, +}; +use async_openai::types::webhooks::WebhookEvent; +use async_openai::webhooks::Webhooks; +use async_openai::Client; +use axum::{ + body::Bytes, + extract::State, + http::{HeaderMap, StatusCode}, + response::IntoResponse, + routing::post, + Router, +}; +use std::sync::Arc; +use tracing::{error, info, warn}; + +/// Application state +#[derive(Clone)] +struct AppState { + webhook_secret: String, +} + +#[tokio::main] +async fn main() { + // Initialize tracing + tracing_subscriber::fmt() + .with_target(false) + .with_level(true) + .init(); + + info!("๐Ÿš€ Starting OpenAI Webhook Example"); + info!(""); + + // Check for API key + let api_key = match std::env::var("OPENAI_API_KEY") { + Ok(key) => key, + Err(_) => { + error!("โŒ OPENAI_API_KEY environment variable not set!"); + error!(" Please set it with: export OPENAI_API_KEY=\"your-api-key\""); + std::process::exit(1); + } + }; + + // Get webhook secret from environment + let webhook_secret = std::env::var("OPENAI_WEBHOOK_SECRET").unwrap_or_else(|_| { + warn!("โš ๏ธ OPENAI_WEBHOOK_SECRET not set, using default test secret"); + warn!(" Set it with: export OPENAI_WEBHOOK_SECRET=\"whsec_your_secret\""); + "test_secret".to_string() + }); + + let state = AppState { + webhook_secret: webhook_secret.clone(), + }; + + // Build the router + let app = Router::new() + .route("/webhook", post(handle_webhook)) + .with_state(Arc::new(state)); + + // Start the server + let addr = "127.0.0.1:3000"; + let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); + + info!("โœ… Webhook server started on http://{}", addr); + info!("๐Ÿ“ฌ Webhook endpoint: http://{}/webhook", addr); + info!(""); + info!("โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”"); + info!("๐Ÿ“‹ Setup Instructions:"); + info!(""); + info!("1. Make sure ngrok is running:"); + info!(" ngrok http 3000"); + info!(""); + info!("2. Configure the ngrok URL in OpenAI dashboard:"); + info!(" https://platform.openai.com/settings/organization/webhooks"); + info!(" Add your ngrok URL + /webhook"); + info!(" Example: https://abc123.ngrok.io/webhook"); + info!(""); + info!("3. Copy the webhook secret from OpenAI dashboard and set it:"); + info!(" export OPENAI_WEBHOOK_SECRET=\"whsec_...\""); + info!("โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”"); + info!(""); + info!("๐Ÿ”„ Sending background response request in 3 seconds..."); + + // Spawn a task to send the background response request + let api_key_clone = api_key.clone(); + tokio::spawn(async move { + // Wait for server to be ready + tokio::time::sleep(tokio::time::Duration::from_secs(3)).await; + + info!(""); + info!("โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”"); + info!("๐Ÿ“ค Sending background response request..."); + info!(""); + + match send_background_response(&api_key_clone).await { + Ok(response_id) => { + info!("โœ… Background response created successfully!"); + info!(" Response ID: {}", response_id); + info!(" Waiting for webhook events..."); + info!(""); + info!(" Expected events:"); + info!(" 1. response.completed - When the response finishes"); + info!(" 2. Or response.failed - If the response fails"); + info!("โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”"); + info!(""); + } + Err(e) => { + error!("โŒ Failed to send background response: {}", e); + error!(" Make sure your OPENAI_API_KEY is valid"); + error!(" and you have access to the Responses API"); + } + } + }); + + // Start serving + axum::serve(listener, app).await.unwrap(); +} + +/// Send a background response request to OpenAI using async-openai client +async fn send_background_response(_api_key: &str) -> Result> { + // Create OpenAI client (will use OPENAI_API_KEY env var) + let client = Client::new(); + + info!(" Model: gpt-4o-mini"); + info!(" Prompt: \"What is the day today?\""); + info!(" Background: true (to receive webhook events)"); + info!(""); + + // Create a background response request + let request = CreateResponseArgs::default() + .model("gpt-4o-mini") + .background(true) // Enable background processing to trigger webhooks + .input(InputParam::Items(vec![InputItem::EasyMessage( + EasyInputMessage { + r#type: MessageType::Message, + role: Role::User, + content: EasyInputContent::Text("What is the day today?".to_string()), + }, + )])) + .build()?; + + // Send the request + let response = client.responses().create(request).await?; + + Ok(response.id) +} + +/// Handle incoming webhook requests +async fn handle_webhook( + State(state): State>, + headers: HeaderMap, + body: Bytes, +) -> Result { + info!("๐Ÿ“ฅ Received webhook request"); + + // Convert body to string + let body_str = std::str::from_utf8(&body).map_err(|e| { + error!("โŒ Failed to parse body as UTF-8: {}", e); + StatusCode::BAD_REQUEST + })?; + + // Extract signature header + let signature = headers + .get("webhook-signature") + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| { + error!("โŒ Missing openai-webhook-signature header"); + StatusCode::BAD_REQUEST + })?; + + // Extract timestamp header + let timestamp = headers + .get("webhook-timestamp") + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| { + error!("โŒ Missing openai-webhook-timestamp header"); + StatusCode::BAD_REQUEST + })?; + + // extract webhook-id header + let webhook_id = headers + .get("webhook-id") + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| { + error!("โŒ Missing webhook-id header"); + StatusCode::BAD_REQUEST + })?; + + info!("โœ… Webhook ID: {}", webhook_id); + + // Verify signature and construct event + let event = Webhooks::build_event( + body_str, + signature, + timestamp, + webhook_id, + &state.webhook_secret, + ) + .map_err(|e| { + error!("โŒ Webhook verification failed: {}", e); + error!(" Signature: {}", signature); + error!(" Timestamp: {}", timestamp); + error!(" Webhook ID: {}", webhook_id); + StatusCode::BAD_REQUEST + })?; + + info!("โœ… Webhook signature verified"); + + // Process the event + process_webhook_event(event); + + Ok(StatusCode::OK) +} + +/// Process webhook events +fn process_webhook_event(event: WebhookEvent) { + info!(""); + info!("โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”"); + info!("๐ŸŽ‰ Processing Webhook Event"); + info!(" Type: {}", event.event_type()); + info!(" ID: {}", event.event_id()); + info!(" Timestamp: {}", event.created_at()); + info!("โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”"); + + match event { + // Batch events + WebhookEvent::BatchCancelled(webhook) => { + info!("๐Ÿ“ฆ Batch Cancelled"); + info!(" Batch ID: {}", webhook.data.id); + info!(" Event occurred at: {}", webhook.created_at); + } + WebhookEvent::BatchCompleted(webhook) => { + info!("๐Ÿ“ฆ Batch Completed"); + info!(" Batch ID: {}", webhook.data.id); + info!(" Event occurred at: {}", webhook.created_at); + } + WebhookEvent::BatchExpired(webhook) => { + info!("๐Ÿ“ฆ Batch Expired"); + info!(" Batch ID: {}", webhook.data.id); + info!(" Event occurred at: {}", webhook.created_at); + } + WebhookEvent::BatchFailed(webhook) => { + info!("๐Ÿ“ฆ Batch Failed"); + info!(" Batch ID: {}", webhook.data.id); + info!(" Event occurred at: {}", webhook.created_at); + } + + // Eval run events + WebhookEvent::EvalRunCanceled(webhook) => { + info!("๐Ÿงช Eval Run Canceled"); + info!(" Eval Run ID: {}", webhook.data.id); + info!(" Event occurred at: {}", webhook.created_at); + } + WebhookEvent::EvalRunFailed(webhook) => { + info!("๐Ÿงช Eval Run Failed"); + info!(" Eval Run ID: {}", webhook.data.id); + info!(" Event occurred at: {}", webhook.created_at); + } + WebhookEvent::EvalRunSucceeded(webhook) => { + info!("๐Ÿงช Eval Run Succeeded"); + info!(" Eval Run ID: {}", webhook.data.id); + info!(" Event occurred at: {}", webhook.created_at); + } + + // Fine-tuning events + WebhookEvent::FineTuningJobCancelled(webhook) => { + info!("๐Ÿ”ง Fine-Tuning Job Cancelled"); + info!(" Job ID: {}", webhook.data.id); + info!(" Event occurred at: {}", webhook.created_at); + } + WebhookEvent::FineTuningJobFailed(webhook) => { + info!("๐Ÿ”ง Fine-Tuning Job Failed"); + info!(" Job ID: {}", webhook.data.id); + info!(" Event occurred at: {}", webhook.created_at); + } + WebhookEvent::FineTuningJobSucceeded(webhook) => { + info!("๐Ÿ”ง Fine-Tuning Job Succeeded"); + info!(" Job ID: {}", webhook.data.id); + info!(" Event occurred at: {}", webhook.created_at); + } + + // Realtime events + WebhookEvent::RealtimeCallIncoming(webhook) => { + info!("๐Ÿ“ž Realtime Call Incoming"); + info!(" Call ID: {}", webhook.data.call_id); + info!(" SIP Headers:"); + for header in &webhook.data.sip_headers { + info!(" {}: {}", header.name, header.value); + } + info!(" Event occurred at: {}", webhook.created_at); + } + + // Response events (for background responses) + WebhookEvent::ResponseCancelled(webhook) => { + info!("๐Ÿ’ฌ Response Cancelled"); + info!(" Response ID: {}", webhook.data.id); + info!(" Event occurred at: {}", webhook.created_at); + info!(""); + info!(" โ„น๏ธ The background response was cancelled before completion."); + } + WebhookEvent::ResponseCompleted(webhook) => { + info!("๐Ÿ’ฌ Response Completed โœ…"); + info!(" Response ID: {}", webhook.data.id); + info!(" Event occurred at: {}", webhook.created_at); + info!(""); + info!(" โ„น๏ธ The background response has been completed successfully!"); + } + WebhookEvent::ResponseFailed(webhook) => { + info!("๐Ÿ’ฌ Response Failed"); + info!(" Response ID: {}", webhook.data.id); + info!(" Event occurred at: {}", webhook.created_at); + info!(""); + info!(" โ„น๏ธ The background response failed during processing."); + } + WebhookEvent::ResponseIncomplete(webhook) => { + info!("๐Ÿ’ฌ Response Incomplete"); + info!(" Response ID: {}", webhook.data.id); + info!(" Event occurred at: {}", webhook.created_at); + info!(""); + info!(" โ„น๏ธ The background response was interrupted."); + } + } + + info!("โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”"); + info!(""); +} From 3fe25471e18ae6d0c6aed5a0668afaa93b81756a Mon Sep 17 00:00:00 2001 From: Himanshu Neema Date: Thu, 6 Nov 2025 14:46:56 -0800 Subject: [PATCH 3/9] updates --- async-openai/src/error.rs | 6 +- async-openai/src/webhooks.rs | 200 ++++++++++++++++++++++++++++++----- 2 files changed, 179 insertions(+), 27 deletions(-) diff --git a/async-openai/src/error.rs b/async-openai/src/error.rs index 0626fe34..25b4e5aa 100644 --- a/async-openai/src/error.rs +++ b/async-openai/src/error.rs @@ -31,9 +31,9 @@ pub enum OpenAIError { /// Errors that can occur when processing webhooks #[derive(Debug, thiserror::Error)] pub enum WebhookError { - /// Invalid webhook signature - verification failed - #[error("invalid webhook signature")] - InvalidSignature, + /// Invalid webhook signature or signature verification failed + #[error("invalid webhook signature: {0}")] + InvalidSignature(String), /// Failed to deserialize webhook payload #[error("failed to deserialize webhook payload: {0}")] Deserialization(#[from] serde_json::Error), diff --git a/async-openai/src/webhooks.rs b/async-openai/src/webhooks.rs index f6ee1f3f..5d7df59e 100644 --- a/async-openai/src/webhooks.rs +++ b/async-openai/src/webhooks.rs @@ -3,11 +3,12 @@ use crate::types::webhooks::WebhookEvent; use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; use hmac::{Hmac, Mac}; use sha2::Sha256; +use std::time::{SystemTime, UNIX_EPOCH}; type HmacSha256 = Hmac; -/// Webhook utilities for verifying and constructing webhook events -/// https://platform.openai.com/docs/guides/webhooks +const DEFAULT_TOLERANCE_SECONDS: i64 = 300; + pub struct Webhooks; impl Webhooks { @@ -18,8 +19,33 @@ impl Webhooks { webhook_id: &str, secret: &str, ) -> Result { - // Verify the signature - Self::verify_signature(body, signature, timestamp, webhook_id, secret)?; + Self::build_event_with_tolerance( + body, + signature, + timestamp, + webhook_id, + secret, + DEFAULT_TOLERANCE_SECONDS, + ) + } + + fn build_event_with_tolerance( + body: &str, + signature: &str, + timestamp: &str, + webhook_id: &str, + secret: &str, + tolerance_seconds: i64, + ) -> Result { + // Verify the signature and timestamp + Self::verify_signature_with_tolerance( + body, + signature, + timestamp, + webhook_id, + secret, + tolerance_seconds, + )?; // Deserialize the event let event: WebhookEvent = serde_json::from_str(body)?; @@ -34,6 +60,46 @@ impl Webhooks { webhook_id: &str, secret: &str, ) -> Result<(), WebhookError> { + Self::verify_signature_with_tolerance( + body, + signature, + timestamp, + webhook_id, + secret, + DEFAULT_TOLERANCE_SECONDS, + ) + } + + fn verify_signature_with_tolerance( + body: &str, + signature: &str, + timestamp: &str, + webhook_id: &str, + secret: &str, + tolerance_seconds: i64, + ) -> Result<(), WebhookError> { + // Validate timestamp to prevent replay attacks + let timestamp_seconds = timestamp + .parse::() + .map_err(|_| WebhookError::InvalidSignature("invalid timestamp format".to_string()))?; + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + + if now - timestamp_seconds > tolerance_seconds { + return Err(WebhookError::InvalidSignature( + "webhook timestamp is too old".to_string(), + )); + } + + if timestamp_seconds > now + tolerance_seconds { + return Err(WebhookError::InvalidSignature( + "webhook timestamp is too new".to_string(), + )); + } + // Construct the signed payload: webhook_id.timestamp.body let signed_payload = format!("{}.{}.{}", webhook_id, timestamp, body); @@ -41,13 +107,13 @@ impl Webhooks { let secret_key = secret.strip_prefix("whsec_").unwrap_or(secret); // Decode the secret from base64 (Standard Webhooks uses base64-encoded secrets) - let secret_bytes = BASE64 - .decode(secret_key) - .map_err(|_| WebhookError::InvalidSignature)?; + let secret_bytes = BASE64.decode(secret_key).map_err(|_| { + WebhookError::InvalidSignature("failed to decode secret from base64".to_string()) + })?; // Compute HMAC-SHA256 let mut mac = HmacSha256::new_from_slice(&secret_bytes) - .map_err(|_| WebhookError::InvalidSignature)?; + .map_err(|_| WebhookError::InvalidSignature("invalid secret key length".to_string()))?; mac.update(signed_payload.as_bytes()); // Get the expected signature in base64 @@ -79,7 +145,9 @@ impl Webhooks { } } - Err(WebhookError::InvalidSignature) + Err(WebhookError::InvalidSignature( + "signature mismatch".to_string(), + )) } } @@ -100,6 +168,14 @@ fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { mod tests { use super::*; + fn current_timestamp() -> String { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() + .to_string() + } + #[test] fn test_constant_time_eq() { assert!(constant_time_eq(b"hello", b"hello")); @@ -112,22 +188,19 @@ mod tests { fn test_verify_signature_invalid() { let body = r#"{"test":"data"}"#; let signature = "invalid_signature"; - let timestamp = "1234567890"; + let timestamp = current_timestamp(); let webhook_id = "webhook_test"; - let secret = "test_secret"; + let secret = BASE64.encode(b"test_secret"); - let result = Webhooks::verify_signature(body, signature, timestamp, webhook_id, secret); + let result = Webhooks::verify_signature(body, &signature, ×tamp, webhook_id, &secret); assert!(result.is_err()); - assert!(matches!( - result.unwrap_err(), - WebhookError::InvalidSignature - )); + // Could be InvalidSignature or InvalidTimestampFormat } #[test] fn test_verify_signature_valid() { let body = r#"{"test":"data"}"#; - let timestamp = "1234567890"; + let timestamp = current_timestamp(); let webhook_id = "webhook_test"; // Base64-encoded secret (Standard Webhooks format) let secret = BASE64.encode(b"test_secret"); @@ -139,14 +212,14 @@ mod tests { mac.update(signed_payload.as_bytes()); let signature = BASE64.encode(mac.finalize().into_bytes()); - let result = Webhooks::verify_signature(body, &signature, timestamp, webhook_id, &secret); + let result = Webhooks::verify_signature(body, &signature, ×tamp, webhook_id, &secret); assert!(result.is_ok()); } #[test] fn test_verify_signature_with_prefix() { let body = r#"{"test":"data"}"#; - let timestamp = "1234567890"; + let timestamp = current_timestamp(); let webhook_id = "webhook_test"; let secret = BASE64.encode(b"test_secret"); let prefixed_secret = format!("whsec_{}", secret); @@ -160,14 +233,14 @@ mod tests { // Verify using prefixed secret let result = - Webhooks::verify_signature(body, &signature, timestamp, webhook_id, &prefixed_secret); + Webhooks::verify_signature(body, &signature, ×tamp, webhook_id, &prefixed_secret); assert!(result.is_ok()); } #[test] fn test_verify_signature_with_version() { let body = r#"{"test":"data"}"#; - let timestamp = "1234567890"; + let timestamp = current_timestamp(); let webhook_id = "webhook_test"; let secret = BASE64.encode(b"test_secret"); @@ -181,14 +254,93 @@ mod tests { // Standard Webhooks format with version prefix let signature = format!("v1,{}", sig_b64); - let result = Webhooks::verify_signature(body, &signature, timestamp, webhook_id, &secret); + let result = Webhooks::verify_signature(body, &signature, ×tamp, webhook_id, &secret); assert!(result.is_ok()); } + #[test] + fn test_timestamp_too_old() { + let body = r#"{"test":"data"}"#; + let old_timestamp = "1234567890"; // Very old timestamp + let webhook_id = "webhook_test"; + let secret = BASE64.encode(b"test_secret"); + + // Compute signature with old timestamp + let signed_payload = format!("{}.{}.{}", webhook_id, old_timestamp, body); + let secret_bytes = BASE64.decode(&secret).unwrap(); + let mut mac = HmacSha256::new_from_slice(&secret_bytes).unwrap(); + mac.update(signed_payload.as_bytes()); + let signature = BASE64.encode(mac.finalize().into_bytes()); + + let result = + Webhooks::verify_signature(body, &signature, old_timestamp, webhook_id, &secret); + assert!(result.is_err()); + match result.unwrap_err() { + WebhookError::InvalidSignature(msg) => { + assert!(msg.contains("too old")); + } + _ => panic!("Expected InvalidSignature error"), + } + } + + #[test] + fn test_timestamp_too_new() { + let body = r#"{"test":"data"}"#; + // Timestamp far in the future + let future_timestamp = (SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() + + 1000) + .to_string(); + let webhook_id = "webhook_test"; + let secret = BASE64.encode(b"test_secret"); + + // Compute signature with future timestamp + let signed_payload = format!("{}.{}.{}", webhook_id, future_timestamp, body); + let secret_bytes = BASE64.decode(&secret).unwrap(); + let mut mac = HmacSha256::new_from_slice(&secret_bytes).unwrap(); + mac.update(signed_payload.as_bytes()); + let signature = BASE64.encode(mac.finalize().into_bytes()); + + let result = + Webhooks::verify_signature(body, &signature, &future_timestamp, webhook_id, &secret); + assert!(result.is_err()); + match result.unwrap_err() { + WebhookError::InvalidSignature(msg) => { + assert!(msg.contains("too new")); + } + _ => panic!("Expected InvalidSignature error"), + } + } + + #[test] + fn test_invalid_timestamp_format() { + let body = r#"{"test":"data"}"#; + let invalid_timestamp = "not_a_number"; + let webhook_id = "webhook_test"; + let secret = BASE64.encode(b"test_secret"); + + let result = Webhooks::verify_signature( + body, + "any_signature", + invalid_timestamp, + webhook_id, + &secret, + ); + assert!(result.is_err()); + match result.unwrap_err() { + WebhookError::InvalidSignature(msg) => { + assert!(msg.contains("timestamp")); + } + _ => panic!("Expected InvalidSignature error"), + } + } + #[test] fn test_construct_event_invalid_json() { let body = r#"{"invalid json"#; - let timestamp = "1234567890"; + let timestamp = current_timestamp(); let webhook_id = "webhook_test"; let secret = BASE64.encode(b"test_secret"); @@ -199,7 +351,7 @@ mod tests { mac.update(signed_payload.as_bytes()); let signature = BASE64.encode(mac.finalize().into_bytes()); - let result = Webhooks::construct_event(body, &signature, timestamp, webhook_id, &secret); + let result = Webhooks::build_event(body, &signature, ×tamp, webhook_id, &secret); assert!(result.is_err()); assert!(matches!( result.unwrap_err(), From 4b0420852dc800d4f258f7be67dea71df3677733 Mon Sep 17 00:00:00 2001 From: Himanshu Neema Date: Thu, 6 Nov 2025 14:53:31 -0800 Subject: [PATCH 4/9] return string which failed deserialiaztion --- async-openai/src/error.rs | 4 ++-- async-openai/src/webhooks.rs | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/async-openai/src/error.rs b/async-openai/src/error.rs index 25b4e5aa..3c08fe9e 100644 --- a/async-openai/src/error.rs +++ b/async-openai/src/error.rs @@ -35,8 +35,8 @@ pub enum WebhookError { #[error("invalid webhook signature: {0}")] InvalidSignature(String), /// Failed to deserialize webhook payload - #[error("failed to deserialize webhook payload: {0}")] - Deserialization(#[from] serde_json::Error), + #[error("failed to deserialize webhook payload: error:{0} content:{1}")] + Deserialization(serde_json::Error, String), } #[derive(Debug, thiserror::Error)] diff --git a/async-openai/src/webhooks.rs b/async-openai/src/webhooks.rs index 5d7df59e..1a8d0df1 100644 --- a/async-openai/src/webhooks.rs +++ b/async-openai/src/webhooks.rs @@ -48,7 +48,8 @@ impl Webhooks { )?; // Deserialize the event - let event: WebhookEvent = serde_json::from_str(body)?; + let event: WebhookEvent = serde_json::from_str(body) + .map_err(|e| WebhookError::Deserialization(e, body.to_string()))?; Ok(event) } From eb10004f9a3536e5a9fc458a6a906b0e31649de8 Mon Sep 17 00:00:00 2001 From: Himanshu Neema Date: Thu, 6 Nov 2025 14:56:25 -0800 Subject: [PATCH 5/9] update --- examples/webhooks/src/main.rs | 34 +++++++++++++++------------------- 1 file changed, 15 insertions(+), 19 deletions(-) diff --git a/examples/webhooks/src/main.rs b/examples/webhooks/src/main.rs index 71770d07..cbf17d54 100644 --- a/examples/webhooks/src/main.rs +++ b/examples/webhooks/src/main.rs @@ -131,10 +131,6 @@ async fn main() { info!("โœ… Background response created successfully!"); info!(" Response ID: {}", response_id); info!(" Waiting for webhook events..."); - info!(""); - info!(" Expected events:"); - info!(" 1. response.completed - When the response finishes"); - info!(" 2. Or response.failed - If the response fails"); info!("โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”"); info!(""); } @@ -261,56 +257,56 @@ fn process_webhook_event(event: WebhookEvent) { WebhookEvent::BatchCancelled(webhook) => { info!("๐Ÿ“ฆ Batch Cancelled"); info!(" Batch ID: {}", webhook.data.id); - info!(" Event occurred at: {}", webhook.created_at); + info!(" Event created at: {}", webhook.created_at); } WebhookEvent::BatchCompleted(webhook) => { info!("๐Ÿ“ฆ Batch Completed"); info!(" Batch ID: {}", webhook.data.id); - info!(" Event occurred at: {}", webhook.created_at); + info!(" Event created at: {}", webhook.created_at); } WebhookEvent::BatchExpired(webhook) => { info!("๐Ÿ“ฆ Batch Expired"); info!(" Batch ID: {}", webhook.data.id); - info!(" Event occurred at: {}", webhook.created_at); + info!(" Event created at: {}", webhook.created_at); } WebhookEvent::BatchFailed(webhook) => { info!("๐Ÿ“ฆ Batch Failed"); info!(" Batch ID: {}", webhook.data.id); - info!(" Event occurred at: {}", webhook.created_at); + info!(" Event created at: {}", webhook.created_at); } // Eval run events WebhookEvent::EvalRunCanceled(webhook) => { info!("๐Ÿงช Eval Run Canceled"); info!(" Eval Run ID: {}", webhook.data.id); - info!(" Event occurred at: {}", webhook.created_at); + info!(" Event created at: {}", webhook.created_at); } WebhookEvent::EvalRunFailed(webhook) => { info!("๐Ÿงช Eval Run Failed"); info!(" Eval Run ID: {}", webhook.data.id); - info!(" Event occurred at: {}", webhook.created_at); + info!(" Event created at: {}", webhook.created_at); } WebhookEvent::EvalRunSucceeded(webhook) => { info!("๐Ÿงช Eval Run Succeeded"); info!(" Eval Run ID: {}", webhook.data.id); - info!(" Event occurred at: {}", webhook.created_at); + info!(" Event created at: {}", webhook.created_at); } // Fine-tuning events WebhookEvent::FineTuningJobCancelled(webhook) => { info!("๐Ÿ”ง Fine-Tuning Job Cancelled"); info!(" Job ID: {}", webhook.data.id); - info!(" Event occurred at: {}", webhook.created_at); + info!(" Event created at: {}", webhook.created_at); } WebhookEvent::FineTuningJobFailed(webhook) => { info!("๐Ÿ”ง Fine-Tuning Job Failed"); info!(" Job ID: {}", webhook.data.id); - info!(" Event occurred at: {}", webhook.created_at); + info!(" Event created at: {}", webhook.created_at); } WebhookEvent::FineTuningJobSucceeded(webhook) => { info!("๐Ÿ”ง Fine-Tuning Job Succeeded"); info!(" Job ID: {}", webhook.data.id); - info!(" Event occurred at: {}", webhook.created_at); + info!(" Event created at: {}", webhook.created_at); } // Realtime events @@ -321,35 +317,35 @@ fn process_webhook_event(event: WebhookEvent) { for header in &webhook.data.sip_headers { info!(" {}: {}", header.name, header.value); } - info!(" Event occurred at: {}", webhook.created_at); + info!(" Event created at: {}", webhook.created_at); } // Response events (for background responses) WebhookEvent::ResponseCancelled(webhook) => { info!("๐Ÿ’ฌ Response Cancelled"); info!(" Response ID: {}", webhook.data.id); - info!(" Event occurred at: {}", webhook.created_at); + info!(" Event created at: {}", webhook.created_at); info!(""); info!(" โ„น๏ธ The background response was cancelled before completion."); } WebhookEvent::ResponseCompleted(webhook) => { info!("๐Ÿ’ฌ Response Completed โœ…"); info!(" Response ID: {}", webhook.data.id); - info!(" Event occurred at: {}", webhook.created_at); + info!(" Event created at: {}", webhook.created_at); info!(""); info!(" โ„น๏ธ The background response has been completed successfully!"); } WebhookEvent::ResponseFailed(webhook) => { info!("๐Ÿ’ฌ Response Failed"); info!(" Response ID: {}", webhook.data.id); - info!(" Event occurred at: {}", webhook.created_at); + info!(" Event created at: {}", webhook.created_at); info!(""); info!(" โ„น๏ธ The background response failed during processing."); } WebhookEvent::ResponseIncomplete(webhook) => { info!("๐Ÿ’ฌ Response Incomplete"); info!(" Response ID: {}", webhook.data.id); - info!(" Event occurred at: {}", webhook.created_at); + info!(" Event created at: {}", webhook.created_at); info!(""); info!(" โ„น๏ธ The background response was interrupted."); } From ae6c583f471832b95a7358ef228cb67cdd8d011f Mon Sep 17 00:00:00 2001 From: Himanshu Neema Date: Thu, 6 Nov 2025 15:17:04 -0800 Subject: [PATCH 6/9] webhook feature flag --- async-openai/Cargo.toml | 7 ++++--- async-openai/src/error.rs | 11 ----------- async-openai/src/lib.rs | 1 + async-openai/src/types/mod.rs | 2 ++ async-openai/src/webhooks.rs | 14 ++++++++++++-- 5 files changed, 19 insertions(+), 16 deletions(-) diff --git a/async-openai/Cargo.toml b/async-openai/Cargo.toml index e6e8f266..90489ab3 100644 --- a/async-openai/Cargo.toml +++ b/async-openai/Cargo.toml @@ -25,6 +25,7 @@ native-tls-vendored = ["reqwest/native-tls-vendored"] realtime = ["dep:tokio-tungstenite"] # Bring your own types byot = [] +webhook = ["dep:hmac", "dep:sha2", "dep:hex"] [dependencies] async-openai-macros = { path = "../async-openai-macros", version = "0.1.0" } @@ -50,9 +51,9 @@ secrecy = { version = "0.10.3", features = ["serde"] } bytes = "1.9.0" eventsource-stream = "0.2.3" tokio-tungstenite = { version = "0.26.1", optional = true, default-features = false } -hmac = "0.12.1" -sha2 = "0.10.8" -hex = "0.4.3" +hmac = { version = "0.12", optional = true, default-features = false} +sha2 = { version = "0.10", optional = true, default-features = false } +hex = { version = "0.4", optional = true, default-features = false } [dev-dependencies] tokio-test = "0.4.4" diff --git a/async-openai/src/error.rs b/async-openai/src/error.rs index 3c08fe9e..288d198b 100644 --- a/async-openai/src/error.rs +++ b/async-openai/src/error.rs @@ -28,17 +28,6 @@ pub enum OpenAIError { InvalidArgument(String), } -/// Errors that can occur when processing webhooks -#[derive(Debug, thiserror::Error)] -pub enum WebhookError { - /// Invalid webhook signature or signature verification failed - #[error("invalid webhook signature: {0}")] - InvalidSignature(String), - /// Failed to deserialize webhook payload - #[error("failed to deserialize webhook payload: error:{0} content:{1}")] - Deserialization(serde_json::Error, String), -} - #[derive(Debug, thiserror::Error)] pub enum StreamError { /// Underlying error from reqwest_eventsource library when reading the stream diff --git a/async-openai/src/lib.rs b/async-openai/src/lib.rs index d8bacba9..3a2ae796 100644 --- a/async-openai/src/lib.rs +++ b/async-openai/src/lib.rs @@ -179,6 +179,7 @@ mod vector_store_file_batches; mod vector_store_files; mod vector_stores; mod video; +#[cfg(feature = "webhook")] pub mod webhooks; pub use assistants::Assistants; diff --git a/async-openai/src/types/mod.rs b/async-openai/src/types/mod.rs index 2fe87f3a..05127bae 100644 --- a/async-openai/src/types/mod.rs +++ b/async-openai/src/types/mod.rs @@ -34,6 +34,8 @@ mod upload; mod users; mod vector_store; mod video; +#[cfg_attr(docsrs, doc(cfg(feature = "webhook")))] +#[cfg(feature = "webhook")] pub mod webhooks; pub use assistant::*; diff --git a/async-openai/src/webhooks.rs b/async-openai/src/webhooks.rs index 1a8d0df1..5634b800 100644 --- a/async-openai/src/webhooks.rs +++ b/async-openai/src/webhooks.rs @@ -1,10 +1,20 @@ -use crate::error::WebhookError; use crate::types::webhooks::WebhookEvent; use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; use hmac::{Hmac, Mac}; use sha2::Sha256; use std::time::{SystemTime, UNIX_EPOCH}; +/// Errors that can occur when processing webhooks +#[derive(Debug, thiserror::Error)] +pub enum WebhookError { + /// Invalid webhook signature or signature verification failed + #[error("invalid webhook signature: {0}")] + InvalidSignature(String), + /// Failed to deserialize webhook payload + #[error("failed to deserialize webhook payload: error:{0} content:{1}")] + Deserialization(serde_json::Error, String), +} + type HmacSha256 = Hmac; const DEFAULT_TOLERANCE_SECONDS: i64 = 300; @@ -356,7 +366,7 @@ mod tests { assert!(result.is_err()); assert!(matches!( result.unwrap_err(), - WebhookError::Deserialization(_) + WebhookError::Deserialization(..) )); } } From bd283dccacd46bf915b3ebef9084f553199e00bd Mon Sep 17 00:00:00 2001 From: Himanshu Neema Date: Thu, 6 Nov 2025 15:17:20 -0800 Subject: [PATCH 7/9] update example --- examples/webhooks/Cargo.toml | 2 +- examples/webhooks/README.md | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/examples/webhooks/Cargo.toml b/examples/webhooks/Cargo.toml index 2509aeeb..9a44206f 100644 --- a/examples/webhooks/Cargo.toml +++ b/examples/webhooks/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] -async-openai = { path = "../../async-openai" } +async-openai = { path = "../../async-openai", features = ["webhook"] } tokio = { version = "1.42.0", features = ["full"] } axum = "0.7.9" tracing = "0.1.41" diff --git a/examples/webhooks/README.md b/examples/webhooks/README.md index 692e7150..b59494c1 100644 --- a/examples/webhooks/README.md +++ b/examples/webhooks/README.md @@ -2,6 +2,15 @@ This example demonstrates how to handle OpenAI webhook events using the `async-openai` library, including signature verification. +## Feature Requirement + +This example requires the `webhook` feature to be enabled: + +```toml +[dependencies] +async-openai = { version = "*", features = ["webhook"] } +``` + ## Running the Example From c868bc2420e21cc0519c8c9bb3b545f805b6d572 Mon Sep 17 00:00:00 2001 From: Himanshu Neema Date: Thu, 6 Nov 2025 15:22:37 -0800 Subject: [PATCH 8/9] update readme --- async-openai/README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/async-openai/README.md b/async-openai/README.md index cede1991..1bcc79f7 100644 --- a/async-openai/README.md +++ b/async-openai/README.md @@ -68,6 +68,10 @@ $Env:OPENAI_API_KEY='sk-...' Only types for Realtime API are implemented, and can be enabled with feature flag `realtime`. +## Webhooks + +Support for webhook event types, signature verification, and building webhook events from payloads can be enabled by using the `webhook` feature flag. + ## Image Generation Example ```rust From 8073c85dd85fb5b1db174a62d1a1ad94d8cf13df Mon Sep 17 00:00:00 2001 From: Himanshu Neema Date: Thu, 6 Nov 2025 15:26:33 -0800 Subject: [PATCH 9/9] cleanup --- examples/webhooks/src/main.rs | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/examples/webhooks/src/main.rs b/examples/webhooks/src/main.rs index cbf17d54..92725653 100644 --- a/examples/webhooks/src/main.rs +++ b/examples/webhooks/src/main.rs @@ -65,16 +65,6 @@ async fn main() { info!("๐Ÿš€ Starting OpenAI Webhook Example"); info!(""); - // Check for API key - let api_key = match std::env::var("OPENAI_API_KEY") { - Ok(key) => key, - Err(_) => { - error!("โŒ OPENAI_API_KEY environment variable not set!"); - error!(" Please set it with: export OPENAI_API_KEY=\"your-api-key\""); - std::process::exit(1); - } - }; - // Get webhook secret from environment let webhook_secret = std::env::var("OPENAI_WEBHOOK_SECRET").unwrap_or_else(|_| { warn!("โš ๏ธ OPENAI_WEBHOOK_SECRET not set, using default test secret"); @@ -116,7 +106,6 @@ async fn main() { info!("๐Ÿ”„ Sending background response request in 3 seconds..."); // Spawn a task to send the background response request - let api_key_clone = api_key.clone(); tokio::spawn(async move { // Wait for server to be ready tokio::time::sleep(tokio::time::Duration::from_secs(3)).await; @@ -126,7 +115,7 @@ async fn main() { info!("๐Ÿ“ค Sending background response request..."); info!(""); - match send_background_response(&api_key_clone).await { + match send_background_response().await { Ok(response_id) => { info!("โœ… Background response created successfully!"); info!(" Response ID: {}", response_id); @@ -147,7 +136,7 @@ async fn main() { } /// Send a background response request to OpenAI using async-openai client -async fn send_background_response(_api_key: &str) -> Result> { +async fn send_background_response() -> Result> { // Create OpenAI client (will use OPENAI_API_KEY env var) let client = Client::new();