1use axum::http::HeaderMap;
9use base64::Engine as _;
10use base64::engine::general_purpose::STANDARD as B64;
11use hmac::{Hmac, KeyInit, Mac};
12use serde::Deserialize;
13use serde_json::Value;
14use sha2::Sha256;
15use time::OffsetDateTime;
16use time::format_description::well_known::Rfc3339;
17use url::Url;
18
19use crate::config::DodoConfig;
20
21pub const TOLERANCE_SECS: i64 = 5 * 60;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
25pub enum SigError {
26 #[error("missing {0} header")]
27 MissingHeader(&'static str),
28 #[error("webhook-timestamp is not a unix timestamp")]
29 BadTimestamp,
30 #[error("webhook-timestamp is outside the 5 minute window")]
31 Stale,
32 #[error("the webhook secret is not a whsec_ key")]
33 BadSecret,
34 #[error("no valid signature")]
35 NoValidSignature,
36}
37
38fn header<'a>(headers: &'a HeaderMap, name: &'static str) -> Result<&'a str, SigError> {
39 headers
40 .get(name)
41 .and_then(|v| v.to_str().ok())
42 .ok_or(SigError::MissingHeader(name))
43}
44
45pub const MIN_KEY_BYTES: usize = 16;
48
49pub fn key(secret: &str) -> Result<Vec<u8>, SigError> {
51 let encoded = secret
52 .trim()
53 .strip_prefix("whsec_")
54 .ok_or(SigError::BadSecret)?;
55 let key = B64.decode(encoded).map_err(|_| SigError::BadSecret)?;
56 if key.len() < MIN_KEY_BYTES {
57 return Err(SigError::BadSecret);
58 }
59 Ok(key)
60}
61
62fn mac(key: &[u8], id: &str, timestamp: &str, body: &[u8]) -> Hmac<Sha256> {
63 let mut mac = Hmac::<Sha256>::new_from_slice(key).expect("HMAC accepts keys of any length");
64 mac.update(id.as_bytes());
65 mac.update(b".");
66 mac.update(timestamp.as_bytes());
67 mac.update(b".");
68 mac.update(body);
69 mac
70}
71
72pub fn verify(
77 secret: &str,
78 headers: &HeaderMap,
79 body: &[u8],
80 now: OffsetDateTime,
81) -> Result<(), SigError> {
82 let id = header(headers, "webhook-id")?;
83 let timestamp = header(headers, "webhook-timestamp")?;
84 let signatures = header(headers, "webhook-signature")?;
85 let sent: i64 = timestamp.parse().map_err(|_| SigError::BadTimestamp)?;
86 if now.unix_timestamp().abs_diff(sent) > TOLERANCE_SECS as u64 {
88 return Err(SigError::Stale);
89 }
90 let key = key(secret)?;
91 let valid = signatures
92 .split_ascii_whitespace()
93 .filter_map(|s| s.strip_prefix("v1,"))
94 .filter_map(|s| B64.decode(s).ok())
95 .any(|sig| mac(&key, id, timestamp, body).verify_slice(&sig).is_ok());
96 if valid {
97 Ok(())
98 } else {
99 Err(SigError::NoValidSignature)
100 }
101}
102
103pub fn sign(secret: &str, id: &str, timestamp: i64, body: &[u8]) -> Result<String, SigError> {
105 let key = key(secret)?;
106 let sig = mac(&key, id, ×tamp.to_string(), body)
107 .finalize()
108 .into_bytes();
109 Ok(format!("v1,{}", B64.encode(sig)))
110}
111
112#[derive(Debug, Clone, PartialEq, Eq)]
115pub struct Subscription {
116 pub subscription_id: Option<String>,
117 pub product_id: Option<String>,
118 pub customer_id: Option<String>,
119 pub customer_email: Option<String>,
120 pub status: Option<String>,
121 pub next_billing_date: Option<OffsetDateTime>,
122 pub metadata_account_id: Option<String>,
124}
125
126#[derive(Debug, Clone, PartialEq, Eq)]
127pub struct DodoEvent {
128 pub id: String,
130 pub kind: String,
132 pub timestamp: Option<OffsetDateTime>,
134 pub subscription: Option<Subscription>,
136}
137
138fn parse_time(text: &str) -> Option<OffsetDateTime> {
140 let text = text.trim();
141 OffsetDateTime::parse(text, &Rfc3339)
142 .or_else(|_| OffsetDateTime::parse(&format!("{text}Z"), &Rfc3339))
143 .ok()
144}
145
146fn text(object: &serde_json::Map<String, Value>, field: &str) -> Option<String> {
149 match object.get(field) {
150 None | Some(Value::Null) => None,
151 Some(Value::String(s)) if !s.trim().is_empty() => Some(s.clone()),
152 Some(Value::String(_)) => None,
153 Some(_) => {
154 tracing::warn!(field, "billing webhook field is not a string, ignoring it");
155 None
156 }
157 }
158}
159
160fn object<'a>(
162 parent: &'a serde_json::Map<String, Value>,
163 field: &str,
164) -> Option<&'a serde_json::Map<String, Value>> {
165 match parent.get(field) {
166 None | Some(Value::Null) => None,
167 Some(Value::Object(o)) => Some(o),
168 Some(_) => {
169 tracing::warn!(field, "billing webhook field is not an object, ignoring it");
170 None
171 }
172 }
173}
174
175#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
177#[error("the webhook body is not a JSON object")]
178pub struct NotAnObject;
179
180pub fn parse_event(id: &str, body: &[u8]) -> Result<DodoEvent, NotAnObject> {
183 let Ok(Value::Object(root)) = serde_json::from_slice::<Value>(body) else {
184 return Err(NotAnObject);
185 };
186 let kind = text(&root, "type").unwrap_or_default();
187 let timestamp = text(&root, "timestamp").as_deref().and_then(parse_time);
188 let subscription = object(&root, "data")
189 .filter(|d| kind.starts_with("subscription.") || d.contains_key("subscription_id"))
190 .map(|d| {
191 let customer = object(d, "customer");
192 Subscription {
193 subscription_id: text(d, "subscription_id"),
194 product_id: text(d, "product_id"),
195 customer_id: customer.and_then(|c| text(c, "customer_id")),
196 customer_email: customer.and_then(|c| text(c, "email")),
197 status: text(d, "status"),
198 next_billing_date: text(d, "next_billing_date").as_deref().and_then(parse_time),
199 metadata_account_id: object(d, "metadata").and_then(|m| text(m, "account_id")),
200 }
201 });
202 Ok(DodoEvent {
203 id: id.to_owned(),
204 kind,
205 timestamp,
206 subscription,
207 })
208}
209
210#[derive(Debug, thiserror::Error)]
213pub enum DodoError {
214 #[error("could not reach Dodo: {0}")]
215 Unreachable(String),
216 #[error("Dodo answered {status}: {body}")]
217 Rejected { status: u16, body: String },
218 #[error("Dodo's answer had no usable {0}")]
219 BadResponse(&'static str),
220}
221
222#[derive(Deserialize)]
223struct CheckoutResponse {
224 checkout_url: Option<String>,
225}
226
227#[derive(Deserialize)]
228struct PortalResponse {
229 link: Option<String>,
230}
231
232async fn post(
233 http: &reqwest::Client,
234 cfg: &DodoConfig,
235 url: Url,
236 body: Option<serde_json::Value>,
237) -> Result<String, DodoError> {
238 let mut req = http.post(url).bearer_auth(&cfg.api_key);
239 if let Some(body) = body {
240 req = req.json(&body);
241 }
242 let res = req
243 .send()
244 .await
245 .map_err(|e| DodoError::Unreachable(e.to_string()))?;
246 let status = res.status();
247 let text = res
248 .text()
249 .await
250 .map_err(|e| DodoError::Unreachable(e.to_string()))?;
251 if !status.is_success() {
252 return Err(DodoError::Rejected {
253 status: status.as_u16(),
254 body: text.chars().take(300).collect(),
255 });
256 }
257 Ok(text)
258}
259
260fn web_url(link: Option<String>, field: &'static str) -> Result<String, DodoError> {
262 link.filter(|l| {
263 Url::parse(l).is_ok_and(|u| matches!(u.scheme(), "http" | "https") && u.host().is_some())
264 })
265 .ok_or(DodoError::BadResponse(field))
266}
267
268pub async fn create_checkout(
270 http: &reqwest::Client,
271 cfg: &DodoConfig,
272 product_id: &str,
273 email: &str,
274 account_id: uuid::Uuid,
275 return_url: &str,
276) -> Result<String, DodoError> {
277 let url = cfg
278 .api_url
279 .join("checkouts")
280 .map_err(|_| DodoError::BadResponse("api url"))?;
281 let text = post(
282 http,
283 cfg,
284 url,
285 Some(serde_json::json!({
286 "product_cart": [{ "product_id": product_id, "quantity": 1 }],
287 "customer": { "email": email },
288 "return_url": return_url,
289 "metadata": { "account_id": account_id.to_string() },
290 })),
291 )
292 .await?;
293 let parsed: CheckoutResponse =
294 serde_json::from_str(&text).map_err(|_| DodoError::BadResponse("checkout_url"))?;
295 web_url(parsed.checkout_url, "checkout_url")
296}
297
298pub async fn create_portal_session(
300 http: &reqwest::Client,
301 cfg: &DodoConfig,
302 customer_id: &str,
303 return_url: &str,
304) -> Result<String, DodoError> {
305 let mut url = cfg
306 .api_url
307 .join(&format!(
308 "customers/{}/customer-portal/session",
309 urlencode(customer_id)
310 ))
311 .map_err(|_| DodoError::BadResponse("api url"))?;
312 url.query_pairs_mut().append_pair("return_url", return_url);
313 let text = post(http, cfg, url, None).await?;
314 let parsed: PortalResponse =
315 serde_json::from_str(&text).map_err(|_| DodoError::BadResponse("link"))?;
316 web_url(parsed.link, "link")
317}
318
319fn urlencode(s: &str) -> String {
321 url::form_urlencoded::byte_serialize(s.as_bytes())
322 .collect::<String>()
323 .replace('+', "%20")
324}
325
326#[cfg(test)]
327mod tests {
328 use super::*;
329 use axum::http::HeaderValue;
330
331 const SECRET: &str = "whsec_dGVzdC1zZWNyZXQtMDEyMzQ1Njc4OQ==";
332 const BODY: &[u8] = br#"{"type":"subscription.active"}"#;
333
334 fn now() -> OffsetDateTime {
335 OffsetDateTime::from_unix_timestamp(1_800_000_000).unwrap()
336 }
337
338 fn headers(id: &str, ts: i64, sig: &str) -> HeaderMap {
339 let mut h = HeaderMap::new();
340 h.insert("webhook-id", HeaderValue::from_str(id).unwrap());
341 h.insert(
342 "webhook-timestamp",
343 HeaderValue::from_str(&ts.to_string()).unwrap(),
344 );
345 h.insert("webhook-signature", HeaderValue::from_str(sig).unwrap());
346 h
347 }
348
349 fn signed(ts: i64) -> HeaderMap {
350 headers("msg_1", ts, &sign(SECRET, "msg_1", ts, BODY).unwrap())
351 }
352
353 #[test]
354 fn a_valid_signature_verifies() {
355 let ts = now().unix_timestamp();
356 assert_eq!(verify(SECRET, &signed(ts), BODY, now()), Ok(()));
357 }
358
359 #[test]
360 fn the_signature_matches_the_standard_webhooks_reference_vector() {
361 let secret = "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw";
364 let body = br#"{"test": 2432232314}"#;
365 assert_eq!(
366 sign(secret, "msg_p5jXN8AQM9LWM0D4loKWxJek", 1_614_265_330, body).unwrap(),
367 "v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE="
368 );
369 }
370
371 #[test]
372 fn a_wrong_secret_is_refused() {
373 let ts = now().unix_timestamp();
374 let other = "whsec_b3RoZXItc2VjcmV0LTAxMjM0NTY3ODk=";
375 assert_eq!(
376 verify(other, &signed(ts), BODY, now()),
377 Err(SigError::NoValidSignature)
378 );
379 }
380
381 #[test]
382 fn a_modified_body_is_refused() {
383 let ts = now().unix_timestamp();
384 assert_eq!(
385 verify(
386 SECRET,
387 &signed(ts),
388 br#"{"type":"subscription.expired"}"#,
389 now()
390 ),
391 Err(SigError::NoValidSignature)
392 );
393 }
394
395 #[test]
396 fn a_modified_id_is_refused() {
397 let ts = now().unix_timestamp();
398 let h = headers("msg_2", ts, &sign(SECRET, "msg_1", ts, BODY).unwrap());
399 assert_eq!(
400 verify(SECRET, &h, BODY, now()),
401 Err(SigError::NoValidSignature)
402 );
403 }
404
405 #[test]
406 fn a_stale_or_future_timestamp_is_refused() {
407 let ts = now().unix_timestamp() - TOLERANCE_SECS - 1;
408 assert_eq!(
409 verify(SECRET, &signed(ts), BODY, now()),
410 Err(SigError::Stale)
411 );
412 let ts = now().unix_timestamp() + TOLERANCE_SECS + 1;
413 assert_eq!(
414 verify(SECRET, &signed(ts), BODY, now()),
415 Err(SigError::Stale)
416 );
417 let ts = now().unix_timestamp() - TOLERANCE_SECS;
419 assert_eq!(verify(SECRET, &signed(ts), BODY, now()), Ok(()));
420 }
421
422 #[test]
423 fn a_missing_header_is_refused() {
424 let ts = now().unix_timestamp();
425 for name in ["webhook-id", "webhook-timestamp", "webhook-signature"] {
426 let mut h = signed(ts);
427 h.remove(name);
428 assert!(
429 matches!(verify(SECRET, &h, BODY, now()), Err(SigError::MissingHeader(n)) if n == name),
430 "{name}"
431 );
432 }
433 }
434
435 #[test]
436 fn a_garbled_timestamp_or_signature_is_refused() {
437 let mut h = signed(now().unix_timestamp());
438 h.insert("webhook-timestamp", HeaderValue::from_static("yesterday"));
439 assert_eq!(verify(SECRET, &h, BODY, now()), Err(SigError::BadTimestamp));
440 let h = headers(
441 "msg_1",
442 now().unix_timestamp(),
443 "v1,%%%not-base64 v2,AAAA nonsense",
444 );
445 assert_eq!(
446 verify(SECRET, &h, BODY, now()),
447 Err(SigError::NoValidSignature)
448 );
449 }
450
451 #[test]
452 fn one_valid_signature_among_several_passes() {
453 let ts = now().unix_timestamp();
454 let good = sign(SECRET, "msg_1", ts, BODY).unwrap();
455 let bad = sign("whsec_b3RoZXItc2VjcmV0LTAxMjM0NTY3ODk=", "msg_1", ts, BODY).unwrap();
456 for list in [
457 format!("{bad} {good}"),
458 format!("{good} {bad}"),
459 format!("v2,AAAA {bad} {good}"),
460 ] {
461 let h = headers("msg_1", ts, &list);
462 assert_eq!(verify(SECRET, &h, BODY, now()), Ok(()), "{list}");
463 }
464 let h = headers("msg_1", ts, &format!("{bad} {bad}"));
465 assert_eq!(
466 verify(SECRET, &h, BODY, now()),
467 Err(SigError::NoValidSignature)
468 );
469 }
470
471 #[test]
472 fn an_empty_or_short_key_is_refused_even_with_a_matching_signature() {
473 let ts = now().unix_timestamp();
474 for short in ["whsec_", "whsec_YQ==", "whsec_c2VjcmV0LTAxMjM0NQ=="] {
475 assert_eq!(
477 sign(short, "msg_1", ts, BODY),
478 Err(SigError::BadSecret),
479 "{short}"
480 );
481 let h = headers("msg_1", ts, "v1,AAAA");
482 assert_eq!(
483 verify(short, &h, BODY, now()),
484 Err(SigError::BadSecret),
485 "{short}"
486 );
487 }
488 assert!(sign("whsec_MDEyMzQ1Njc4OWFiY2RlZg==", "msg_1", ts, BODY).is_ok());
490 }
491
492 #[test]
493 fn extreme_timestamps_are_refused_without_panicking() {
494 for extreme in [i64::MIN, i64::MAX, i64::MIN + 1, 0] {
495 let h = headers("msg_1", extreme, "v1,AAAA");
496 assert_eq!(
497 verify(SECRET, &h, BODY, now()),
498 Err(SigError::Stale),
499 "{extreme}"
500 );
501 }
502 }
503
504 #[test]
505 fn a_field_of_the_wrong_type_is_treated_as_absent() {
506 let body = br#"{
507 "type": "subscription.active",
508 "timestamp": 1760000000,
509 "data": {
510 "subscription_id": "sub_1",
511 "product_id": ["pdt_pro"],
512 "status": 7,
513 "next_billing_date": {"at": "soon"},
514 "customer": "cus_1",
515 "metadata": "account"
516 }
517 }"#;
518 let ev = parse_event("m", body).unwrap();
519 assert_eq!(ev.kind, "subscription.active");
520 assert_eq!(ev.timestamp, None);
521 let sub = ev.subscription.unwrap();
522 assert_eq!(sub.subscription_id.as_deref(), Some("sub_1"));
523 assert_eq!(sub.product_id, None);
524 assert_eq!(sub.status, None);
525 assert_eq!(sub.next_billing_date, None);
526 assert_eq!(sub.customer_id, None);
527 assert_eq!(sub.metadata_account_id, None);
528
529 for bad in ["[]", "7", "\"text\"", "null", "not json"] {
531 assert!(parse_event("m", bad.as_bytes()).is_err(), "{bad}");
532 }
533 let ev = parse_event("m", br#"{"type": 5, "data": 5}"#).unwrap();
534 assert_eq!(ev.kind, "");
535 assert!(ev.subscription.is_none());
536 }
537
538 #[test]
539 fn the_config_does_not_print_its_secrets() {
540 let cfg = DodoConfig {
541 api_key: "dodo_key_SUPERSECRET".to_owned(),
542 webhook_secret: "whsec_WEBHOOKSECRET".to_owned(),
543 product_pro: "pdt_pro".to_owned(),
544 product_agency: "pdt_agency".to_owned(),
545 api_url: Url::parse("https://test.dodopayments.com").unwrap(),
546 };
547 let shown = format!("{cfg:?}");
548 assert!(
549 !shown.contains("SUPERSECRET") && !shown.contains("WEBHOOKSECRET"),
550 "{shown}"
551 );
552 assert!(shown.contains("pdt_pro") && shown.contains("test.dodopayments.com"));
553 }
554
555 #[test]
556 fn a_secret_that_is_not_whsec_is_a_configuration_error() {
557 let ts = now().unix_timestamp();
558 assert_eq!(
559 verify("plain", &signed(ts), BODY, now()),
560 Err(SigError::BadSecret)
561 );
562 }
563
564 #[test]
565 fn a_subscription_event_is_read() {
566 let body = br#"{
567 "business_id": "bus_1",
568 "type": "subscription.active",
569 "timestamp": "2026-10-05T10:00:00.123456Z",
570 "data": {
571 "payload_type": "Subscription",
572 "subscription_id": "sub_1",
573 "product_id": "pdt_pro",
574 "status": "active",
575 "next_billing_date": "2026-11-05T10:00:00Z",
576 "customer": {"customer_id": "cus_1", "email": "ana@example.com", "name": "Ana"},
577 "metadata": {"account_id": "6f1d9a54-6b0e-4a39-9f43-0c2a8b6d8e11"},
578 "something_new": {"nested": [1, 2, 3]}
579 },
580 "also_new": true
581 }"#;
582 let ev = parse_event("msg_1", body).unwrap();
583 assert_eq!(ev.id, "msg_1");
584 assert_eq!(ev.kind, "subscription.active");
585 assert_eq!(
586 ev.timestamp,
587 Some(OffsetDateTime::parse("2026-10-05T10:00:00.123456Z", &Rfc3339).unwrap())
588 );
589 let sub = ev.subscription.unwrap();
590 assert_eq!(sub.subscription_id.as_deref(), Some("sub_1"));
591 assert_eq!(sub.product_id.as_deref(), Some("pdt_pro"));
592 assert_eq!(sub.customer_id.as_deref(), Some("cus_1"));
593 assert_eq!(sub.customer_email.as_deref(), Some("ana@example.com"));
594 assert_eq!(sub.status.as_deref(), Some("active"));
595 assert_eq!(
596 sub.next_billing_date,
597 Some(OffsetDateTime::parse("2026-11-05T10:00:00Z", &Rfc3339).unwrap())
598 );
599 assert_eq!(
600 sub.metadata_account_id.as_deref(),
601 Some("6f1d9a54-6b0e-4a39-9f43-0c2a8b6d8e11")
602 );
603 }
604
605 #[test]
606 fn missing_fields_and_other_events_are_tolerated() {
607 let ev = parse_event("m", br#"{"type":"subscription.on_hold","data":{}}"#).unwrap();
608 let sub = ev.subscription.unwrap();
609 assert_eq!(sub.subscription_id, None);
610 assert_eq!(ev.timestamp, None);
611
612 let ev = parse_event(
613 "m",
614 br#"{"type":"payment.succeeded","data":{"payment_id":"p"}}"#,
615 )
616 .unwrap();
617 assert_eq!(ev.kind, "payment.succeeded");
618 assert!(ev.subscription.is_none());
619
620 let ev = parse_event("m", br#"{"type":"x","timestamp":"2026-10-05T10:00:00"}"#).unwrap();
622 assert!(ev.timestamp.is_some());
623 let ev = parse_event("m", br#"{"type":"x","timestamp":"soon"}"#).unwrap();
624 assert!(ev.timestamp.is_none());
625
626 assert!(parse_event("m", b"not json").is_err());
627 }
628}