Skip to main content

max / makenotwork

v0.6.1: accept multiple Stripe webhook signing secrets Stripe forces one endpoint per scope ("Your account" vs "Connected accounts"), so MNW now runs two destinations (mnw-connect, mnw-you) on the same URL. Each has its own signing secret. STRIPE_WEBHOOK_SECRET is now a comma-separated list; verify_webhook tries each in turn. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-17 15:23 UTC
Commit: 4e4896c33315f471fcdcb67fd3c4d7e840d54791
Parent: 01e2fee
6 files changed, +52 insertions, -33 deletions
@@ -3551,7 +3551,7 @@ dependencies = [
3551 3551
3552 3552 [[package]]
3553 3553 name = "makenotwork"
3554 - version = "0.6.0"
3554 + version = "0.6.1"
3555 3555 dependencies = [
3556 3556 "anyhow",
3557 3557 "argon2",
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makenotwork"
3 - version = "0.6.0"
3 + version = "0.6.1"
4 4 edition = "2024"
5 5 license-file = "LICENSE"
6 6
@@ -50,10 +50,15 @@ is missing on our side. After this migration deploys, resend that event.
50 50
51 51 5. **Currency moved to `stripe_types::Currency`.**
52 52
53 - 6. **Connect events** may need a separate Stripe dashboard subscription. The
54 - testaccount123 stuck event is a Connect event (`evt_1TXpkh0...0AcRNJbwd4...`).
55 - The May 12 events that did process were platform-level. Confirm Connect
56 - event subscription on the `mnw-alpha` endpoint as part of A2.7.
53 + 6. **Connect events live on a separate endpoint** from platform events.
54 + Stripe forces one endpoint per scope: "Your account" vs "Connected accounts".
55 + We now run **two destinations** both pointing at `https://makenot.work/stripe/webhook`:
56 + - `mnw-connect` → Events from connected accounts (fan→creator flow,
57 + creator onboarding, refunds). testaccount123's stuck event lives here.
58 + - `mnw-you` → Events from MNW's own account (Fan+ subs, creator tier subs,
59 + app sync subs, MNW's own subscription).
60 + Each endpoint has its own signing secret. `STRIPE_WEBHOOK_SECRET` accepts
61 + a comma-separated list; `verify_signature` tries each in turn.
57 62
58 63 7. **Smoke test proved rc.5 parses our fixtures.** See `/tmp/stripe-rc5-parse/`
59 64 (scratch crate, can be regenerated; main.rs in that crate is the working
@@ -161,23 +166,17 @@ PWYW checkout in browser at localhost using test card `4242…`.
161 166 every outgoing API call (create session × 3 modes, create subscription,
162 167 fetch balance, fetch account, fetch subscription, refund). Run in CI.
163 168 Catches future schema breaks before prod sees them.
164 - - **A2.7**: In Stripe Dashboard, confirm `mnw-alpha` (or the new endpoint we
165 - ended up with) is subscribed to **Connect events on connected accounts**,
166 - not just platform events. Connect events drive every creator transaction;
167 - if this checkbox is off, deployment success doesn't translate to working
168 - purchases.
169 -
170 - ## State of related infrastructure
171 -
172 - - Webhook signing secret was rotated to `whsec_pNXOKYm9P5bFuVwsbJPxZYw1OOy1cGsu`
173 - on prod at `/opt/makenotwork/.env` (backup at
174 - `/opt/makenotwork/.env.bak-<timestamp>`). Service was restarted. Signature
175 - verification now succeeds; parsing fails, which is what the rest of this
176 - migration fixes.
177 - - Old endpoint `mnw-alpha` is still active in Stripe Dashboard. Decision
178 - pending: keep it on the rotated secret or create a fresh endpoint pinned to
179 - the rc.5-supported API version. Right now we plan to keep the existing
180 - endpoint since rc.5 handles the current API version natively.
169 + - **A2.7** (resolved 2026-05-17): Two endpoints configured — `mnw-connect`
170 + (Events from Connected accounts, 7 events) and `mnw-you` (Events from Your
171 + account, same 7 events). Both deliver to `https://makenot.work/stripe/webhook`.
172 +
173 + ## State of related infrastructure (2026-05-17)
174 +
175 + - Old `mnw-alpha` endpoint was deleted during the back-pin attempt. Replaced
176 + with two new endpoints (see A2.7 above), each with its own signing secret.
177 + - `STRIPE_WEBHOOK_SECRET` is now a comma-separated list of the two secrets;
178 + `verify_signature` iterates and accepts a match against any of them.
179 + - v0.6.0 deployed to prod 2026-05-17. Service active, health 200.
181 180
182 181 ## Other bugs filed during this session (for reference, not migration scope)
183 182
@@ -313,8 +313,14 @@ impl std::fmt::Debug for ScanConfig {
313 313 pub struct StripeConfig {
314 314 /// Stripe secret API key (sk_test_... or sk_live_...)
315 315 pub secret_key: String,
316 - /// Webhook signing secret for v1 snapshot events (whsec_...)
317 - pub webhook_secret: String,
316 + /// Webhook signing secrets for v1 snapshot events (whsec_...).
317 + ///
318 + /// A list to accommodate multiple Stripe endpoints (e.g. `mnw-connect`
319 + /// for Connected-account events + `mnw-you` for platform events — Stripe
320 + /// requires one endpoint per scope, and each endpoint has its own secret).
321 + /// `verify_signature` accepts a match against any secret in the list.
322 + /// Configured via `STRIPE_WEBHOOK_SECRET` as a comma-separated list.
323 + pub webhook_secret: Vec<String>,
318 324 /// Webhook signing secret for v2 thin events (whsec_...)
319 325 /// Optional — v2 endpoint returns 503 if not set.
320 326 pub webhook_secret_v2: Option<String>,
@@ -325,7 +331,12 @@ impl StripeConfig {
325 331 /// Returns None if any required variable is missing (graceful degradation)
326 332 pub fn from_env() -> Option<Self> {
327 333 let secret_key = std::env::var("STRIPE_SECRET_KEY").ok()?;
328 - let webhook_secret = std::env::var("STRIPE_WEBHOOK_SECRET").ok()?;
334 + let webhook_secret: Vec<String> = std::env::var("STRIPE_WEBHOOK_SECRET").ok()?
335 + .split(',')
336 + .map(|s| s.trim().to_string())
337 + .filter(|s| !s.is_empty())
338 + .collect();
339 + if webhook_secret.is_empty() { return None; }
329 340 let webhook_secret_v2 = std::env::var("STRIPE_WEBHOOK_SECRET_V2").ok();
330 341
331 342 Some(StripeConfig {
@@ -712,7 +723,7 @@ mod tests {
712 723 let config = Config::from_env().expect("should succeed");
713 724 let stripe = config.stripe.expect("stripe should be Some when fully configured");
714 725 assert_eq!(stripe.secret_key, "sk_test_abc");
715 - assert_eq!(stripe.webhook_secret, "whsec_test");
726 + assert_eq!(stripe.webhook_secret, vec!["whsec_test".to_string()]);
716 727 assert!(stripe.webhook_secret_v2.is_none());
717 728 drop(guard);
718 729 }
@@ -54,13 +54,22 @@ fn take_string(v: &mut serde_json::Value, key: &str) -> Option<String> {
54 54
55 55 impl StripeClient {
56 56 /// Verify the webhook signature and return the parsed envelope.
57 + ///
58 + /// Tries each configured signing secret in turn and accepts on the first
59 + /// match. We run multiple endpoints (`mnw-connect`, `mnw-you`), each with
60 + /// its own secret; signatures don't carry an endpoint id, so checking
61 + /// every secret is the only option.
57 62 #[tracing::instrument(skip_all, name = "payments::verify_webhook")]
58 63 pub fn verify_webhook(&self, payload: &str, signature: &str) -> Result<UntypedEvent> {
59 - verify_signature(payload, signature, &self.config.webhook_secret).map_err(|e| {
60 - tracing::warn!(error = %e, "webhook signature verification failed");
61 - AppError::BadRequest("Invalid webhook signature".to_string())
62 - })?;
63 - UntypedEvent::from_payload(payload)
64 + let mut last_err: Option<String> = None;
65 + for secret in &self.config.webhook_secret {
66 + match verify_signature(payload, signature, secret) {
67 + Ok(()) => return UntypedEvent::from_payload(payload),
68 + Err(e) => last_err = Some(e),
69 + }
70 + }
71 + tracing::warn!(error = ?last_err, "webhook signature verification failed against all configured secrets");
72 + Err(AppError::BadRequest("Invalid webhook signature".to_string()))
64 73 }
65 74
66 75 /// Verify a v2 thin event webhook and return the parsed JSON body.
@@ -147,7 +147,7 @@ impl TestHarness {
147 147 pub async fn with_stripe() -> Self {
148 148 let stripe_config = StripeConfig {
149 149 secret_key: "sk_test_fake_key_for_testing".to_string(),
150 - webhook_secret: stripe::TEST_WEBHOOK_SECRET.to_string(),
150 + webhook_secret: vec![stripe::TEST_WEBHOOK_SECRET.to_string()],
151 151 webhook_secret_v2: Some(stripe::TEST_WEBHOOK_SECRET_V2.to_string()),
152 152 };
153 153 let stripe_client: Arc<dyn PaymentProvider> = Arc::new(StripeClient::new(&stripe_config));