stripe_webhook/
webhook.rs

1use std::str::FromStr;
2
3use chrono::Utc;
4use hmac::{Hmac, Mac};
5use sha2::Sha256;
6use stripe_shared::event::EventType;
7use stripe_shared::ApiVersion;
8
9use crate::{EventObject, WebhookError};
10
11#[derive(Clone, Debug)]
12#[cfg_attr(feature = "serialize", derive(serde::Serialize))]
13#[cfg_attr(feature = "deserialize", derive(serde::Deserialize))]
14pub struct Event {
15    /// The connected account that originated the event.
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    pub api_version: Option<ApiVersion>,
21    /// Time at which the object was created.
22    ///
23    /// Measured in seconds since the Unix epoch.
24    pub created: stripe_types::Timestamp,
25    pub data: EventData,
26    /// Unique identifier for the object.
27    pub id: stripe_shared::event::EventId,
28    /// Has the value `true` if the object exists in live mode or the value `false` if the object exists in test mode.
29    pub livemode: bool,
30    /// Number of webhooks that have yet to be successfully delivered (i.e., to return a 20x response) to the URLs you've specified.
31    pub pending_webhooks: i64,
32    /// Information on the API request that instigated the event.
33    pub request: Option<stripe_shared::NotificationEventRequest>,
34    /// Description of the event (e.g., `invoice.created` or `charge.refunded`).
35    pub type_: EventType,
36}
37
38#[derive(Clone, Debug)]
39#[cfg_attr(feature = "serialize", derive(serde::Serialize))]
40#[cfg_attr(feature = "deserialize", derive(serde::Deserialize))]
41pub struct EventData {
42    /// Object containing the API resource relevant to the event.
43    ///
44    /// 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.
45    pub object: EventObject,
46    /// Object containing the names of the updated attributes and their values prior to the event (only included in events of type `*.updated`).
47    ///
48    /// If an array attribute has any updated elements, this object contains the entire array.
49    /// In Stripe API versions 2017-04-06 or earlier, an updated array attribute in this object includes only the updated array elements.
50    #[cfg_attr(
51        any(feature = "deserialize", feature = "serialize"),
52        serde(with = "stripe_types::with_serde_json_opt")
53    )]
54    pub previous_attributes: Option<miniserde::json::Value>,
55}
56
57#[derive(Debug)]
58pub struct Webhook {
59    current_timestamp: i64,
60}
61
62impl Webhook {
63    /// Construct an event from a webhook payload, **ignoring the secret**.
64    ///
65    /// This method is considered insecure and intended for early-stage local testing only.
66    /// Use [construct_event](Self::construct_event) for production instead.
67    ///
68    /// # Errors
69    ///
70    /// This function will return a WebhookError if the payload could not be parsed
71    pub fn insecure(payload: &str) -> Result<Event, WebhookError> {
72        Self { current_timestamp: 0 }.parse_payload(payload)
73    }
74
75    /// Construct an event from a webhook payload and signature.
76    ///
77    /// # Errors
78    ///
79    /// This function will return a WebhookError if:
80    ///  - the provided signature is invalid
81    ///  - the provided secret is invalid
82    ///  - the signature timestamp is older than 5 minutes
83    ///  - the payload could not be parsed
84    pub fn construct_event(payload: &str, sig: &str, secret: &str) -> Result<Event, WebhookError> {
85        Self { current_timestamp: Utc::now().timestamp() }.do_construct_event(payload, sig, secret)
86    }
87
88    /// Construct an event from a webhook payload and signature, verifying its signature
89    /// using the provided timestamp.
90    ///
91    /// This is helpful for replaying requests in tests and should be avoided otherwise
92    /// in production use.
93    ///
94    /// # Errors
95    ///
96    /// This function will return a WebhookError if:
97    /// - the provided signature is invalid
98    /// - the provided secret is invalid
99    /// - the signature timestamp is older than 5 minutes from the provided timestamp
100    /// - the payload could not be parsed
101    pub fn construct_event_with_timestamp(
102        payload: &str,
103        sig: &str,
104        secret: &str,
105        timestamp: i64,
106    ) -> Result<Event, WebhookError> {
107        Self { current_timestamp: timestamp }.do_construct_event(payload, sig, secret)
108    }
109
110    fn do_construct_event(
111        self,
112        payload: &str,
113        sig: &str,
114        secret: &str,
115    ) -> Result<Event, WebhookError> {
116        // Get Stripe signature from header
117        let signature = Signature::parse(sig)?;
118        let signed_payload = format!("{}.{}", signature.t, payload);
119
120        // Compute HMAC with the SHA256 hash function, using endpoint secret as key
121        // and signed_payload string as the message.
122        let mut mac =
123            Hmac::<Sha256>::new_from_slice(secret.as_bytes()).map_err(|_| WebhookError::BadKey)?;
124        mac.update(signed_payload.as_bytes());
125
126        let sig = hex::decode(signature.v1).map_err(|_| WebhookError::BadSignature)?;
127        mac.verify_slice(sig.as_slice()).map_err(|_| WebhookError::BadSignature)?;
128
129        // Get current timestamp to compare to signature timestamp
130        if (self.current_timestamp - signature.t).abs() > 300 {
131            return Err(WebhookError::BadTimestamp(signature.t));
132        }
133
134        self.parse_payload(payload)
135    }
136
137    fn parse_payload(self, payload: &str) -> Result<Event, WebhookError> {
138        let base_evt: stripe_shared::Event = miniserde::json::from_str(payload)
139            .map_err(|_| WebhookError::BadParse("could not deserialize webhook event".into()))?;
140
141        let event_obj =
142            EventObject::from_raw_data(base_evt.type_.as_str(), base_evt.data.object)
143                .ok_or_else(|| WebhookError::BadParse("could not parse event object".into()))?;
144
145        Ok(Event {
146            account: base_evt.account,
147            api_version: base_evt
148                .api_version
149                .map(|s| ApiVersion::from_str(&s).unwrap_or(ApiVersion::Unknown(s))),
150            created: base_evt.created,
151            data: EventData {
152                object: event_obj,
153                previous_attributes: base_evt.data.previous_attributes,
154            },
155            id: base_evt.id,
156            livemode: base_evt.livemode,
157            pending_webhooks: base_evt.pending_webhooks,
158            request: base_evt.request,
159            type_: base_evt.type_,
160        })
161    }
162}
163
164#[derive(Debug)]
165struct Signature<'r> {
166    t: i64,
167    v1: &'r str,
168}
169
170impl<'r> Signature<'r> {
171    fn parse(raw: &'r str) -> Result<Signature<'r>, WebhookError> {
172        let mut t: Option<i64> = None;
173        let mut v1: Option<&'r str> = None;
174        for pair in raw.split(',') {
175            let (key, val) = pair.split_once('=').ok_or(WebhookError::BadSignature)?;
176            match key {
177                "t" => {
178                    t = Some(val.parse().map_err(WebhookError::BadHeader)?);
179                }
180                "v1" => {
181                    v1 = Some(val);
182                }
183                _ => {}
184            }
185        }
186        Ok(Signature {
187            t: t.ok_or(WebhookError::BadSignature)?,
188            v1: v1.ok_or(WebhookError::BadSignature)?,
189        })
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use serde_json::{json, Value};
196
197    use super::*;
198    use crate::{AccountExternalAccountCreated, EventType};
199
200    const WEBHOOK_SECRET: &str = "secret";
201
202    #[test]
203    fn test_signature_parse() {
204        let raw_signature =
205            "t=1492774577,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd";
206        let signature = Signature::parse(raw_signature).unwrap();
207        assert_eq!(signature.t, 1492774577);
208        assert_eq!(
209            signature.v1,
210            "5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd"
211        );
212
213        let raw_signature_with_test_mode = "t=1492774577,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd,v0=6ffbb59b2300aae63f272406069a9788598b792a944a07aba816edb039989a39";
214        let signature = Signature::parse(raw_signature_with_test_mode).unwrap();
215        assert_eq!(signature.t, 1492774577);
216        assert_eq!(
217            signature.v1,
218            "5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd"
219        );
220    }
221
222    fn get_mock_stripe_sig(msg: &str, timestamp: i64) -> String {
223        let signed_payload = format!("{timestamp}.{msg}");
224
225        let mut mac = Hmac::<Sha256>::new_from_slice(WEBHOOK_SECRET.as_bytes()).unwrap();
226
227        mac.update(signed_payload.as_bytes());
228        let result = mac.finalize().into_bytes();
229        let v1 = hex::encode(&result[..]);
230        format!("t={timestamp},v1={v1}")
231    }
232
233    fn mock_webhook_event(event_type: &EventType, data: Value) -> Value {
234        json!({
235            "id": "evt_123",
236            "object": "event",
237            "account": "acct_123",
238            "api_version": "2017-05-25",
239            "created": 1533204620,
240            "livemode": false,
241            "pending_webhooks": 1,
242            "request": {
243                "id": "req_123",
244                "idempotency_key": "idempotency-key-123"
245            },
246            "data": {
247                "object": data,
248            },
249            "type": event_type.to_string()
250        })
251    }
252
253    #[track_caller]
254    fn parse_mock_webhook_event(event_type: EventType, data: Value) -> EventObject {
255        let now = Utc::now().timestamp();
256        let payload = mock_webhook_event(&event_type, data).to_string();
257        let sig = get_mock_stripe_sig(&payload, now);
258
259        let webhook = Webhook { current_timestamp: now };
260        let parsed = webhook.do_construct_event(&payload, &sig, WEBHOOK_SECRET).unwrap();
261        assert_eq!(parsed.type_, event_type);
262        parsed.data.object
263    }
264
265    #[test]
266    fn test_webhook_construct_event() {
267        let object = json!({
268            "id": "ii_123",
269            "object": "invoiceitem",
270            "amount": 1000,
271            "currency": "usd",
272            "customer": "cus_123",
273            "date": 1533204620,
274            "description": "Test Invoice Item",
275            "discountable": false,
276            "invoice": "in_123",
277            "livemode": false,
278            "metadata": {},
279            "period": {
280                "start": 1533204620,
281                "end": 1533204620
282            },
283            "proration": false,
284            "quantity": 3
285        });
286        let payload = mock_webhook_event(&EventType::InvoiceitemCreated, object);
287        let event_timestamp = 1533204620;
288        let signature = format!("t={event_timestamp},v1=5a81ebe328da1df19581cbc6c7377920947ffd30b56eebcc7ba9a6938a090965,v0=63f3a72374a733066c4be69ed7f8e5ac85c22c9f0a6a612ab9a025a9e4ee7eef");
289
290        let webhook = Webhook { current_timestamp: event_timestamp };
291
292        let event = webhook
293            .do_construct_event(&payload.to_string(), &signature, WEBHOOK_SECRET)
294            .expect("Failed to construct event");
295
296        assert_eq!(event.type_, EventType::InvoiceitemCreated);
297        assert_eq!(event.id.as_str(), "evt_123",);
298        assert_eq!(event.account, "acct_123".parse().ok());
299        assert_eq!(event.created, 1533204620);
300
301        let EventObject::InvoiceitemCreated(invoice) = event.data.object else {
302            panic!("expected invoice item created");
303        };
304        assert_eq!(invoice.id.as_str(), "ii_123");
305        assert_eq!(invoice.quantity, 3);
306    }
307
308    #[cfg(feature = "async-stripe-billing")]
309    #[test]
310    // https://github.com/arlyon/async-stripe/issues/455
311    fn test_billing_portal_session() {
312        let object = json!({
313            "configuration": "bpc_123",
314            "created": 1533204620,
315            "customer": "cus_123",
316            "id": "bps_123",
317            "livemode": false,
318            "url": "http://localhost:3000"
319        });
320        let result = parse_mock_webhook_event(EventType::BillingPortalSessionCreated, object);
321        let EventObject::BillingPortalSessionCreated(session) = result else {
322            panic!("expected billing portal session");
323        };
324        assert_eq!(session.url, "http://localhost:3000");
325        assert_eq!(session.id.as_str(), "bps_123");
326        assert_eq!(session.configuration.id().as_str(), "bpc_123");
327    }
328
329    #[test]
330    fn deserialize_polymorphic() {
331        let object = json!({
332            "object": "bank_account",
333            "country": "us",
334            "currency": "gbp",
335            "id": "ba_123",
336            "last4": "1234",
337            "status": "status",
338        });
339        let result = parse_mock_webhook_event(EventType::AccountExternalAccountCreated, object);
340        let EventObject::AccountExternalAccountCreated(AccountExternalAccountCreated::BankAccount(
341            bank_account,
342        )) = result
343        else {
344            panic!("unexpected type parsed");
345        };
346        assert_eq!(bank_account.id.as_str(), "ba_123");
347        assert_eq!(bank_account.last4, "1234");
348    }
349}