diff --git a/.env.example b/.env.example
index 7318d2d..b7dd576 100644
--- a/.env.example
+++ b/.env.example
@@ -94,7 +94,11 @@ MAIL_FROM_NAME=CluPilot
# ── Stripe (payments) ────────────────────────────────────────────────────
# WEBHOOK_SECRET is READ BY CODE today (signature check on /webhooks/stripe).
# Outside local/testing a missing secret makes the webhook fail closed.
+# Stripe hands out a SEPARATE signing secret per operating mode (test/live) —
+# fill in the one matching each. Which one applies follows platform.mode
+# (App\Support\StripeWebhookSecret), not this file.
STRIPE_WEBHOOK_SECRET=
+STRIPE_WEBHOOK_SECRET_TEST=
# Not read yet — needed once the Checkout flow is built (currently the portal
# only records a purchase intent):
STRIPE_KEY=
diff --git a/app/Http/Controllers/StripeWebhookController.php b/app/Http/Controllers/StripeWebhookController.php
index fabe785..b1f4c19 100644
--- a/app/Http/Controllers/StripeWebhookController.php
+++ b/app/Http/Controllers/StripeWebhookController.php
@@ -4,6 +4,7 @@ namespace App\Http\Controllers;
use App\Actions\ApplyStripeBillingEvent;
use App\Actions\StartCustomerProvisioning;
+use App\Support\StripeWebhookSecret;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -20,7 +21,7 @@ class StripeWebhookController extends Controller
ApplyStripeBillingEvent $billing,
): JsonResponse {
$payload = $request->getContent();
- $secret = (string) config('services.stripe.webhook_secret');
+ $secret = StripeWebhookSecret::current();
if (blank($secret)) {
// Fail closed: an unconfigured secret must not authorize provisioning
diff --git a/app/Support/StripeWebhookSecret.php b/app/Support/StripeWebhookSecret.php
new file mode 100644
index 0000000..e1c7acb
--- /dev/null
+++ b/app/Support/StripeWebhookSecret.php
@@ -0,0 +1,33 @@
+isTest()
+ ? (string) config('services.stripe.webhook_secret_test')
+ : (string) config('services.stripe.webhook_secret');
+ }
+}
diff --git a/config/services.php b/config/services.php
index 84462ab..e8cec2a 100644
--- a/config/services.php
+++ b/config/services.php
@@ -37,6 +37,11 @@ return [
'stripe' => [
'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'),
+ // Stripe issues a SEPARATE signing secret per operating mode. Which of
+ // the two applies is decided by App\Support\StripeWebhookSecret, not
+ // here — see its header comment for why that lookup still touches the
+ // database despite both values living in the .env.
+ 'webhook_secret_test' => env('STRIPE_WEBHOOK_SECRET_TEST'),
// Secret key for the REST API. Blank means "not connected": the
// catalogue sync says so and does nothing, rather than half-creating
// products against an account that is not there.
diff --git a/phpunit.xml b/phpunit.xml
index bbd3a88..9a33a84 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -30,11 +30,15 @@
-
+ an invalid signature. The tests that DO exercise verification set
+ the secret(s) themselves. _TEST is the live secret's sibling
+ (StripeWebhookSecret picks between them by operating mode) — same
+ variable class, same risk, so it gets the same treatment even
+ though no current test happens to need it. -->
+