Skip to main content

codoseo_web/billing/
dodo.rs

1//! Dodo Payments: webhook signatures (Standard Webhooks), the subscription envelope, and the
2//! two API calls the app makes (checkout and customer portal).
3//!
4//! Field names in the webhook payload follow Dodo's documentation. They are read leniently
5//! (everything optional, unknown fields ignored) and should be confirmed against payloads from
6//! Dodo's live webhook deliveries before launch.
7
8use 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
21/// How far a webhook's timestamp may be from now, either way.
22pub 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
45/// The shortest webhook key accepted. HMAC takes a key of any length, an empty one included,
46/// so without a floor a `whsec_` secret with nothing after it would "verify" forgeries.
47pub const MIN_KEY_BYTES: usize = 16;
48
49/// The HMAC key behind a `whsec_<base64>` secret.
50pub 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
72/// Checks a webhook the Standard Webhooks way: `webhook-id`, `webhook-timestamp` (unix seconds,
73/// within five minutes of `now`) and `webhook-signature` (`v1,<base64 HMAC-SHA256 of
74/// "{id}.{timestamp}.{body}">`, several separated by spaces; one valid is enough). Signatures
75/// are compared in constant time.
76pub 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    // `abs_diff` can't overflow, whatever the sender put in the header.
87    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
103/// The `webhook-signature` value for a body: what Dodo sends, and what tests send.
104pub fn sign(secret: &str, id: &str, timestamp: i64, body: &[u8]) -> Result<String, SigError> {
105    let key = key(secret)?;
106    let sig = mac(&key, id, &timestamp.to_string(), body)
107        .finalize()
108        .into_bytes();
109    Ok(format!("v1,{}", B64.encode(sig)))
110}
111
112// ---- the envelope -------------------------------------------------------------------------------
113
114#[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    /// `metadata.account_id`, set when the checkout was created.
123    pub metadata_account_id: Option<String>,
124}
125
126#[derive(Debug, Clone, PartialEq, Eq)]
127pub struct DodoEvent {
128    /// The `webhook-id` header; the body has no id of its own.
129    pub id: String,
130    /// `subscription.active`, ...
131    pub kind: String,
132    /// When it happened, from the body; `None` if missing or unreadable.
133    pub timestamp: Option<OffsetDateTime>,
134    /// Present on subscription events.
135    pub subscription: Option<Subscription>,
136}
137
138/// ISO 8601 with an offset, or without one (taken as UTC).
139fn 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
146/// A string field, or `None` when it is missing, null, empty, or not a string (logged: the
147/// signature was valid, so a surprise in the payload is worth a line, not a rejection).
148fn 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
160/// An object field, or `None` (logged) when it is something else.
161fn 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/// The body wasn't a JSON object.
176#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
177#[error("the webhook body is not a JSON object")]
178pub struct NotAnObject;
179
180/// Reads a verified webhook body. Fails only when it isn't a JSON object; every field inside
181/// is optional, and one of the wrong type counts as absent.
182pub 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// ---- API calls ----------------------------------------------------------------------------------
211
212#[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
260/// A link Dodo gave us must at least be a web address before we redirect to it.
261fn 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
268/// Starts a hosted checkout for `product_id`; returns the page to send the customer to.
269pub 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
298/// Opens a customer-portal session; returns the link to send the customer to.
299pub 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
319/// Escapes a path segment.
320fn 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        // From the Standard Webhooks test suite: secret MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw,
362        // id msg_p5jXN8AQM9LWM0D4loKWxJek, timestamp 1614265330.
363        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        // Right at the edge is still fine.
418        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            // 0, 1 and 15 bytes: the HMAC would accept them, so `key` must not.
476            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        // 16 bytes is the smallest accepted.
489        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        // Only something that isn't a JSON object at all is refused.
530        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        // A timestamp without an offset is read as UTC; garbage is "unknown".
621        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}