Skip to main content

stripe_webhook/
webhook.rs

1use std::str::FromStr;
2
3use chrono::Utc;
4use hmac::{Hmac, Mac};
5use sha2::Sha256;
6use stripe_shared::ApiVersion;
7use stripe_shared::event::EventType;
8
9use crate::{EventObject, WebhookError};
10
11#[derive(Clone, Debug)]
12#[cfg_attr(feature = "serialize", derive(serde::Serialize))]
13pub struct Event {
14    /// The connected account that originated the event.
15    #[cfg_attr(feature = "serialize", serde(skip_serializing_if = "Option::is_none"))]
16    pub account: Option<String>,
17    /// The Stripe API version used to render `data`.
18    ///
19    /// *Note: This property is populated only for events on or after October 31, 2014*.
20    #[cfg_attr(feature = "serialize", serde(skip_serializing_if = "Option::is_none"))]
21    pub api_version: Option<ApiVersion>,
22    /// Time at which the object was created.
23    ///
24    /// Measured in seconds since the Unix epoch.
25    pub created: stripe_types::Timestamp,
26    pub data: EventData,
27    /// Unique identifier for the object.
28    pub id: stripe_shared::event::EventId,
29    /// Has the value `true` if the object exists in live mode or the value `false` if the object exists in test mode.
30    pub livemode: bool,
31    /// String representing the object's type. Always has the value "event".
32    #[cfg_attr(feature = "serialize", serde(rename = "object"))]
33    pub object: EventObjectType,
34    /// Number of webhooks that have yet to be successfully delivered (i.e., to return a 20x response) to the URLs you've specified.
35    pub pending_webhooks: i64,
36    /// Information on the API request that instigated the event.
37    #[cfg_attr(feature = "serialize", serde(skip_serializing_if = "Option::is_none"))]
38    pub request: Option<stripe_shared::NotificationEventRequest>,
39    /// Description of the event (e.g., `invoice.created` or `charge.refunded`).
40    #[cfg_attr(feature = "serialize", serde(rename = "type"))]
41    pub type_: EventType,
42}
43
44/// The object type for Event. Always has the value "event".
45#[derive(Clone, Debug, PartialEq, Eq)]
46#[cfg_attr(feature = "serialize", derive(serde::Serialize))]
47#[cfg_attr(feature = "deserialize", derive(serde::Deserialize))]
48pub enum EventObjectType {
49    #[cfg_attr(any(feature = "serialize", feature = "deserialize"), serde(rename = "event"))]
50    Event,
51}
52
53#[derive(Clone, Debug)]
54#[cfg_attr(feature = "serialize", derive(serde::Serialize))]
55#[cfg_attr(feature = "deserialize", derive(serde::Deserialize))]
56pub struct EventData {
57    /// Object containing the API resource relevant to the event.
58    ///
59    /// For example, an `invoice.created` event will have a full [invoice object](https://stripe.com/docs/api#invoice_object) as the value of the object key.
60    pub object: EventObject,
61    /// Object containing the names of the updated attributes and their values prior to the event (only included in events of type `*.updated`).
62    ///
63    /// If an array attribute has any updated elements, this object contains the entire array.
64    /// In Stripe API versions 2017-04-06 or earlier, an updated array attribute in this object includes only the updated array elements.
65    #[cfg_attr(
66        any(feature = "deserialize", feature = "serialize"),
67        serde(with = "stripe_types::with_serde_json_opt")
68    )]
69    #[cfg_attr(feature = "serialize", serde(skip_serializing_if = "Option::is_none"))]
70    pub previous_attributes: Option<miniserde::json::Value>,
71}
72
73// Custom Deserialize implementation for Event using the Shadow Struct pattern.
74// This allows us to use the `type` field to determine how to deserialize `data.object`.
75#[cfg(feature = "deserialize")]
76impl<'de> serde::Deserialize<'de> for Event {
77    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
78    where
79        D: serde::Deserializer<'de>,
80    {
81        use serde::de::Error;
82
83        // Shadow struct that mirrors Event but keeps data as raw JSON
84        #[derive(serde::Deserialize)]
85        struct EventProxy {
86            pub account: Option<String>,
87            pub api_version: Option<ApiVersion>,
88            pub created: stripe_types::Timestamp,
89            pub id: stripe_shared::event::EventId,
90            pub livemode: bool,
91            #[serde(rename = "object")]
92            #[allow(dead_code)]
93            pub object_type: String, // This will be "event", we just ignore it
94            pub pending_webhooks: i64,
95            pub request: Option<stripe_shared::NotificationEventRequest>,
96            #[serde(rename = "type")]
97            pub type_: EventType,
98            pub data: serde_json::Value,
99        }
100
101        // Let Serde do the heavy lifting
102        let proxy = EventProxy::deserialize(deserializer)?;
103
104        // Extract data.object
105        let object_value =
106            proxy.data.get("object").ok_or_else(|| Error::missing_field("data.object"))?.clone();
107
108        // Use EventObject::from_json_value with the event type
109        let object =
110            EventObject::from_json_value(proxy.type_.as_str(), object_value).map_err(|e| {
111                // If the error already has a path (like "customer: missing field"),
112                // prefix it with "data.object."
113                if e.contains(':') {
114                    Error::custom(format!("data.object.{e}"))
115                } else {
116                    // For errors without paths (like "unknown event type"), just prefix with "data.object: "
117                    Error::custom(format!("data.object: {e}"))
118                }
119            })?;
120
121        // Extract previous_attributes if present and convert to miniserde::json::Value
122        let previous_attributes =
123            if let Some(prev_attrs) = proxy.data.get("previous_attributes") {
124                let prev_attrs_str = serde_json::to_string(prev_attrs).map_err(|e| {
125                    Error::custom(format!("Failed to serialize previous_attributes: {e}"))
126                })?;
127                Some(miniserde::json::from_str(&prev_attrs_str).map_err(|e| {
128                    Error::custom(format!("Failed to parse previous_attributes: {e}"))
129                })?)
130            } else {
131                None
132            };
133
134        Ok(Event {
135            account: proxy.account,
136            api_version: proxy.api_version,
137            created: proxy.created,
138            data: EventData { object, previous_attributes },
139            id: proxy.id,
140            livemode: proxy.livemode,
141            object: EventObjectType::Event,
142            pending_webhooks: proxy.pending_webhooks,
143            request: proxy.request,
144            type_: proxy.type_,
145        })
146    }
147}
148
149#[derive(Debug)]
150pub struct Webhook {
151    current_timestamp: i64,
152}
153
154impl Webhook {
155    /// Generate a test signature header for webhook testing.
156    ///
157    /// This method generates a properly formatted Stripe signature header that can be used
158    /// to test webhook signature verification end-to-end. It's particularly useful for
159    /// integration tests where you want to verify the entire webhook flow.
160    ///
161    /// # Arguments
162    ///
163    /// * `payload` - The webhook payload (JSON string) to generate a signature for
164    /// * `secret` - The webhook signing secret (e.g., "whsec_test_secret")
165    /// * `timestamp` - Optional Unix timestamp to use for the signature. If None, uses current time.
166    ///
167    /// # Returns
168    ///
169    /// A signature header string in the format: "t={timestamp},v1={signature}"
170    ///
171    /// # Examples
172    ///
173    /// ```
174    /// use stripe_webhook::Webhook;
175    ///
176    /// let payload = r#"{
177    ///     "id": "evt_test",
178    ///     "object": "event",
179    ///     "api_version": "2017-05-25",
180    ///     "created": 1492774577,
181    ///     "livemode": false,
182    ///     "pending_webhooks": 1,
183    ///     "data": {
184    ///         "object": {
185    ///             "object": "bank_account",
186    ///             "country": "us",
187    ///             "currency": "usd",
188    ///             "id": "ba_test",
189    ///             "last4": "6789",
190    ///             "status": "verified"
191    ///         }
192    ///     },
193    ///     "type": "account.external_account.created"
194    /// }"#;
195    /// let secret = "whsec_test_secret";
196    ///
197    /// // Generate a test signature
198    /// let signature = Webhook::generate_test_header(payload, secret, None);
199    ///
200    /// // Use it to verify the webhook
201    /// let event = Webhook::construct_event(payload, &signature, secret).unwrap();
202    /// assert_eq!(event.id.as_str(), "evt_test");
203    /// ```
204    pub fn generate_test_header(payload: &str, secret: &str, timestamp: Option<i64>) -> String {
205        let timestamp = timestamp.unwrap_or_else(|| Utc::now().timestamp());
206        let signed_payload = format!("{timestamp}.{payload}");
207
208        let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
209            .expect("HMAC can take key of any size");
210        mac.update(signed_payload.as_bytes());
211        let result = mac.finalize().into_bytes();
212        let v1 = hex::encode(&result[..]);
213
214        format!("t={timestamp},v1={v1}")
215    }
216
217    /// Construct an event from a webhook payload, **ignoring the secret**.
218    ///
219    /// This method is considered insecure and intended for early-stage local testing only.
220    /// Use [construct_event](Self::construct_event) for production instead.
221    ///
222    /// # Errors
223    ///
224    /// This function will return a WebhookError if the payload could not be parsed
225    pub fn insecure(payload: &str) -> Result<Event, WebhookError> {
226        if !cfg!(debug_assertions) {
227            tracing::warn!(
228                "Webhook::insecure() bypasses signature verification and should only be used for local testing. \
229                Use Webhook::construct_event() for production code."
230            );
231        }
232        Self { current_timestamp: 0 }.parse_payload(payload)
233    }
234
235    /// Construct an event from a webhook payload and signature.
236    ///
237    /// # Errors
238    ///
239    /// This function will return a WebhookError if:
240    ///  - the provided signature is invalid
241    ///  - the provided secret is invalid
242    ///  - the signature timestamp is older than 5 minutes
243    ///  - the payload could not be parsed
244    pub fn construct_event(payload: &str, sig: &str, secret: &str) -> Result<Event, WebhookError> {
245        Self { current_timestamp: Utc::now().timestamp() }.do_construct_event(payload, sig, secret)
246    }
247
248    /// Construct an event from a webhook payload and signature, verifying its signature
249    /// using the provided timestamp.
250    ///
251    /// This is helpful for replaying requests in tests and should be avoided otherwise
252    /// in production use.
253    ///
254    /// # Errors
255    ///
256    /// This function will return a WebhookError if:
257    /// - the provided signature is invalid
258    /// - the provided secret is invalid
259    /// - the signature timestamp is older than 5 minutes from the provided timestamp
260    /// - the payload could not be parsed
261    pub fn construct_event_with_timestamp(
262        payload: &str,
263        sig: &str,
264        secret: &str,
265        timestamp: i64,
266    ) -> Result<Event, WebhookError> {
267        Self { current_timestamp: timestamp }.do_construct_event(payload, sig, secret)
268    }
269
270    fn do_construct_event(
271        self,
272        payload: &str,
273        sig: &str,
274        secret: &str,
275    ) -> Result<Event, WebhookError> {
276        // Get Stripe signature from header
277        let signature = Signature::parse(sig)?;
278        let signed_payload = format!("{}.{}", signature.t, payload);
279
280        // Compute HMAC with the SHA256 hash function, using endpoint secret as key
281        // and signed_payload string as the message.
282        let mut mac =
283            Hmac::<Sha256>::new_from_slice(secret.as_bytes()).map_err(|_| WebhookError::BadKey)?;
284        mac.update(signed_payload.as_bytes());
285
286        let sig = hex::decode(signature.v1).map_err(|_| WebhookError::BadSignature)?;
287        mac.verify_slice(sig.as_slice()).map_err(|_| WebhookError::BadSignature)?;
288
289        // Get current timestamp to compare to signature timestamp
290        if (self.current_timestamp - signature.t).abs() > 300 {
291            return Err(WebhookError::BadTimestamp(signature.t));
292        }
293
294        self.parse_payload(payload)
295    }
296
297    #[tracing::instrument]
298    fn parse_payload(self, payload: &str) -> Result<Event, WebhookError> {
299        let base_evt: stripe_shared::Event = miniserde::json::from_str(payload)
300            .map_err(|_| WebhookError::BadParse("could not deserialize webhook event".into()))?;
301
302        let event_obj =
303            EventObject::from_raw_data(base_evt.type_.as_str(), base_evt.data.object)
304                .ok_or_else(|| WebhookError::BadParse("could not parse event object".into()))?;
305
306        // Check for API version mismatch
307        let api_version = base_evt.api_version.as_ref().and_then(|s| ApiVersion::from_str(s).ok());
308
309        if let Some(event_version) = &api_version {
310            if event_version != &stripe_shared::version::VERSION {
311                tracing::warn!(
312                    event_version=?event_version,
313                    sdk_version=?stripe_shared::version::VERSION,
314                    "API version mismatch: SDK compiled with {:?}, but event received with {:?}",
315                    stripe_shared::version::VERSION,
316                    event_version
317                );
318            }
319        }
320
321        Ok(Event {
322            account: base_evt.account,
323            api_version: base_evt
324                .api_version
325                .map(|s| ApiVersion::from_str(&s).expect("infallible")),
326            created: base_evt.created,
327            data: EventData {
328                object: event_obj,
329                previous_attributes: base_evt.data.previous_attributes,
330            },
331            id: base_evt.id,
332            livemode: base_evt.livemode,
333            object: EventObjectType::Event,
334            pending_webhooks: base_evt.pending_webhooks,
335            request: base_evt.request,
336            type_: base_evt.type_,
337        })
338    }
339}
340
341#[derive(Debug)]
342struct Signature<'r> {
343    t: i64,
344    v1: &'r str,
345}
346
347impl<'r> Signature<'r> {
348    fn parse(raw: &'r str) -> Result<Signature<'r>, WebhookError> {
349        let mut t: Option<i64> = None;
350        let mut v1: Option<&'r str> = None;
351        for pair in raw.split(',') {
352            let (key, val) = pair.split_once('=').ok_or(WebhookError::BadSignature)?;
353            match key {
354                "t" => {
355                    t = Some(val.parse().map_err(WebhookError::BadHeader)?);
356                }
357                "v1" => {
358                    v1 = Some(val);
359                }
360                _ => {}
361            }
362        }
363        Ok(Signature {
364            t: t.ok_or(WebhookError::BadSignature)?,
365            v1: v1.ok_or(WebhookError::BadSignature)?,
366        })
367    }
368}
369
370#[cfg(test)]
371mod tests {
372    use serde_json::{Value, json};
373
374    use super::*;
375    use crate::{AccountExternalAccountCreated, EventType};
376
377    const WEBHOOK_SECRET: &str = "secret";
378
379    #[test]
380    fn test_signature_parse() {
381        let raw_signature =
382            "t=1492774577,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd";
383        let signature = Signature::parse(raw_signature).unwrap();
384        assert_eq!(signature.t, 1492774577);
385        assert_eq!(
386            signature.v1,
387            "5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd"
388        );
389
390        let raw_signature_with_test_mode = "t=1492774577,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd,v0=6ffbb59b2300aae63f272406069a9788598b792a944a07aba816edb039989a39";
391        let signature = Signature::parse(raw_signature_with_test_mode).unwrap();
392        assert_eq!(signature.t, 1492774577);
393        assert_eq!(
394            signature.v1,
395            "5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd"
396        );
397    }
398
399    #[test]
400    fn test_generate_test_header() {
401        let payload = json!({
402            "id": "evt_test",
403            "object": "event",
404            "api_version": "2017-05-25",
405            "created": 1492774577,
406            "livemode": false,
407            "pending_webhooks": 1,
408            "data": {
409                "object": {
410                    "object": "bank_account",
411                    "country": "us",
412                    "currency": "usd",
413                    "id": "ba_test",
414                    "last4": "6789",
415                    "status": "verified",
416                }
417            },
418            "type": "account.external_account.created"
419        })
420        .to_string();
421        let secret = "whsec_test_secret";
422        let timestamp = 1492774577;
423
424        // Generate a test signature with explicit timestamp
425        let signature = Webhook::generate_test_header(&payload, secret, Some(timestamp));
426
427        // Verify the signature format
428        assert!(signature.starts_with("t=1492774577,v1="));
429
430        // Verify the signature can be used to construct an event
431        let event =
432            Webhook::construct_event_with_timestamp(&payload, &signature, secret, timestamp);
433        match event {
434            Ok(e) => {
435                assert_eq!(e.id.as_str(), "evt_test");
436                assert_eq!(e.type_, EventType::AccountExternalAccountCreated);
437            }
438            Err(e) => panic!("panic! {}", e),
439        }
440    }
441
442    #[test]
443    fn test_generate_test_header_integration() {
444        // This test demonstrates end-to-end webhook signature testing
445        let payload = json!({
446            "id": "evt_test_webhook",
447            "object": "event",
448            "api_version": "2017-05-25",
449            "created": 1533204620,
450            "livemode": false,
451            "pending_webhooks": 1,
452            "data": {
453                "object": {
454                    "object": "bank_account",
455                    "country": "us",
456                    "currency": "usd",
457                    "id": "ba_test",
458                    "last4": "6789",
459                    "status": "verified",
460                }
461            },
462            "type": "account.external_account.created"
463        })
464        .to_string();
465
466        let secret = "whsec_test_secret";
467        let timestamp = Utc::now().timestamp();
468
469        // Generate test signature
470        let signature = Webhook::generate_test_header(&payload, secret, Some(timestamp));
471
472        // Verify it works with construct_event_with_timestamp
473        let result =
474            Webhook::construct_event_with_timestamp(&payload, &signature, secret, timestamp);
475        assert!(result.is_ok());
476
477        let event = result.unwrap();
478        assert_eq!(event.id.as_str(), "evt_test_webhook");
479        assert_eq!(event.type_, EventType::AccountExternalAccountCreated);
480    }
481
482    fn get_mock_stripe_sig(msg: &str, timestamp: i64) -> String {
483        Webhook::generate_test_header(msg, WEBHOOK_SECRET, Some(timestamp))
484    }
485
486    fn mock_webhook_event(event_type: &EventType, data: Value) -> Value {
487        json!({
488            "id": "evt_123",
489            "object": "event",
490            "account": "acct_123",
491            "api_version": "2017-05-25",
492            "created": 1533204620,
493            "livemode": false,
494            "pending_webhooks": 1,
495            "request": {
496                "id": "req_123",
497                "idempotency_key": "idempotency-key-123"
498            },
499            "data": {
500                "object": data,
501            },
502            "type": event_type.to_string()
503        })
504    }
505
506    #[track_caller]
507    fn parse_mock_webhook_event(event_type: EventType, data: Value) -> EventObject {
508        let now = Utc::now().timestamp();
509        let payload = mock_webhook_event(&event_type, data).to_string();
510        let sig = get_mock_stripe_sig(&payload, now);
511
512        let webhook = Webhook { current_timestamp: now };
513        let parsed = webhook.do_construct_event(&payload, &sig, WEBHOOK_SECRET).unwrap();
514        assert_eq!(parsed.type_, event_type);
515        parsed.data.object
516    }
517
518    #[test]
519    #[cfg(feature = "async-stripe-billing")]
520    fn test_webhook_construct_event() {
521        let object = json!({
522            "id": "ii_123",
523            "object": "invoiceitem",
524            "amount": 1000,
525            "currency": "usd",
526            "customer": "cus_123",
527            "date": 1533204620,
528            "description": "Test Invoice Item",
529            "discountable": false,
530            "invoice": "in_123",
531            "livemode": false,
532            "metadata": {},
533            "period": {
534                "start": 1533204620,
535                "end": 1533204620
536            },
537            "proration": false,
538            "quantity": 3,
539            "quantity_decimal": "0"
540        });
541        let payload = mock_webhook_event(&EventType::InvoiceitemCreated, object);
542        let event_timestamp = 1533204620;
543        let signature = format!(
544            "t={event_timestamp},v1=d7373bc68f4bd320b253cd7461f87af6e1cdf1b4d7db1614d8d1d746972d2d0a,v0=63f3a72374a733066c4be69ed7f8e5ac85c22c9f0a6a612ab9a025a9e4ee7eef"
545        );
546
547        let webhook = Webhook { current_timestamp: event_timestamp };
548
549        let event = webhook
550            .do_construct_event(&payload.to_string(), &signature, WEBHOOK_SECRET)
551            .expect("Failed to construct event");
552
553        assert_eq!(event.type_, EventType::InvoiceitemCreated);
554        assert_eq!(event.id.as_str(), "evt_123",);
555        assert_eq!(event.account, "acct_123".parse().ok());
556        assert_eq!(event.created, 1533204620);
557
558        let EventObject::InvoiceitemCreated(invoice) = event.data.object else {
559            panic!("expected invoice item created");
560        };
561        assert_eq!(invoice.id.as_str(), "ii_123");
562        assert_eq!(invoice.quantity, 3);
563    }
564
565    #[cfg(feature = "async-stripe-billing")]
566    #[test]
567    // https://github.com/arlyon/async-stripe/issues/455
568    fn test_billing_portal_session() {
569        let object = json!({
570            "configuration": "bpc_123",
571            "created": 1533204620,
572            "customer": "cus_123",
573            "id": "bps_123",
574            "livemode": false,
575            "url": "http://localhost:3000"
576        });
577        let result = parse_mock_webhook_event(EventType::BillingPortalSessionCreated, object);
578        let EventObject::BillingPortalSessionCreated(session) = result else {
579            panic!("expected billing portal session");
580        };
581        assert_eq!(session.url, "http://localhost:3000");
582        assert_eq!(session.id.as_str(), "bps_123");
583        assert_eq!(session.configuration.id().as_str(), "bpc_123");
584    }
585
586    #[test]
587    fn deserialize_polymorphic() {
588        let object = json!({
589            "object": "bank_account",
590            "country": "us",
591            "currency": "gbp",
592            "id": "ba_123",
593            "last4": "1234",
594            "status": "status",
595        });
596        let result = parse_mock_webhook_event(EventType::AccountExternalAccountCreated, object);
597        let EventObject::AccountExternalAccountCreated(bank_account) = result else {
598            panic!("unexpected type parsed");
599        };
600        let AccountExternalAccountCreated::BankAccount(bank_account) = *bank_account else {
601            panic!("unexpected type parsed");
602        };
603        assert_eq!(bank_account.id.as_str(), "ba_123");
604        assert_eq!(bank_account.last4, "1234");
605    }
606}