feat(#16): Stripe webhook handler + wired "Book a consult" CTA#121
Conversation
Extends the Stripe commerce scaffolding (still inert until secrets set): - Worker: POST /api/stripe-webhook verifies the Stripe-Signature via Web Crypto HMAC-SHA256 (constant-time compare) and acknowledges checkout.session.completed. Returns 503 NOT_CONFIGURED until STRIPE_WEBHOOK_SECRET is set; 400 on bad/missing signature. Added the env field. - consult.astro: a "Book a consult" CTA that POSTs to /api/checkout and redirects to the returned Stripe URL. Degrades gracefully — shows a status message on 503 (checkout not configured) and hides itself if no consult API base is configured. Remaining for #16 (yours): provision Stripe (secret + price + webhook endpoint), then verify a live transaction and update omega.json #11. Verified: lint, typecheck:strict (0 hints), build (CTA renders), worker esbuild + contract tests 6/6. Stripe paths are not runtime-verified (no Cloudflare runtime here; worker is deploy-on-demand). https://claude.ai/code/session_01PW9DnyVijUNmUJ4qMfgpHn
Reviewer's GuideImplements a Stripe checkout integration by adding a consult page CTA that posts to a new checkout API, and a worker-side Stripe webhook endpoint that verifies webhook signatures via Web Crypto and acknowledges checkout completion events, gated by new Stripe webhook configuration. Sequence diagram for Book a consult CTA checkout flowsequenceDiagram
actor User
participant Browser as consult_astro
participant CheckoutAPI as handleCheckout
participant Stripe
User->>Browser: click book-consult-btn
Browser->>Browser: initBookCta (astro:page-load)
Browser->>CheckoutAPI: fetch(CHECKOUT_API_URL, POST)
CheckoutAPI-->>Browser: 200 { url }
Browser->>Browser: window.location.href = url
Browser->>Stripe: HTTPS redirect to checkout
alt checkout not configured (503)
CheckoutAPI-->>Browser: 503
Browser->>Browser: status.textContent = "Online booking isn't set up yet..."
end
alt network or other error
Browser->>Browser: status.textContent = "Could not reach checkout..."
end
Sequence diagram for Stripe webhook verification handlersequenceDiagram
participant Stripe
participant Worker as fetch
participant WebhookHandler as handleStripeWebhook
participant Verifier as verifyStripeSignature
Stripe->>Worker: POST /api/stripe-webhook
Worker->>WebhookHandler: handleStripeWebhook(request, env, corsHeaders)
alt STRIPE_WEBHOOK_SECRET missing
WebhookHandler-->>Stripe: 503 NOT_CONFIGURED (jsonResponse)
else secret configured
WebhookHandler->>Verifier: verifyStripeSignature(payload, sigHeader, secret)
Verifier-->>WebhookHandler: boolean
alt signature invalid
WebhookHandler-->>Stripe: 400 BAD_SIGNATURE (jsonResponse)
else signature valid
WebhookHandler->>WebhookHandler: JSON.parse(payload)
alt event.type === checkout.session.completed
WebhookHandler->>WebhookHandler: console.log(session id)
end
WebhookHandler-->>Stripe: 200 { ok: true, received: true }
end
end
File-Level Changes
Possibly linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
There was a problem hiding this comment.
Code Review
This pull request introduces a "Book a consult" call-to-action button on the consult page and implements a Stripe webhook endpoint (/api/stripe-webhook) with signature verification in the backend worker. The review feedback identifies critical security enhancements for the webhook signature verification—specifically addressing whitespace handling, replay attack prevention, and key rotation support—as well as a layout adjustment to hide the entire CTA container rather than just the button when the checkout API is unconfigured.
| async function verifyStripeSignature( | ||
| payload: string, | ||
| sigHeader: string, | ||
| secret: string, | ||
| ): Promise<boolean> { | ||
| // Stripe-Signature header: "t=<ts>,v1=<hex hmac>[,v1=...]" | ||
| const parts: Record<string, string> = {}; | ||
| for (const seg of sigHeader.split(',')) { | ||
| const idx = seg.indexOf('='); | ||
| if (idx > 0) parts[seg.slice(0, idx)] = seg.slice(idx + 1); | ||
| } | ||
| const t = parts.t; | ||
| const v1 = parts.v1; | ||
| if (!t || !v1) return false; | ||
| const key = await crypto.subtle.importKey( | ||
| 'raw', | ||
| new TextEncoder().encode(secret), | ||
| { name: 'HMAC', hash: 'SHA-256' }, | ||
| false, | ||
| ['sign'], | ||
| ); | ||
| const mac = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(`${t}.${payload}`)); | ||
| const expected = [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, '0')).join(''); | ||
| // Constant-time comparison. | ||
| if (expected.length !== v1.length) return false; | ||
| let diff = 0; | ||
| for (let i = 0; i < expected.length; i++) diff |= expected.charCodeAt(i) ^ v1.charCodeAt(i); | ||
| return diff === 0; | ||
| } |
There was a problem hiding this comment.
The current signature verification implementation has three main issues:
- Whitespace Vulnerability: Splitting the
Stripe-Signatureheader by,without trimming whitespace can cause verification to fail if leading/trailing spaces are present (e.g.,t=..., v1=...). - Replay Attack Vulnerability: The timestamp
tis parsed but never validated against the current time. Attackers could intercept and replay valid webhook payloads indefinitely. Stripe recommends verifying that the timestamp is within a 5-minute (300 seconds) tolerance. - Multiple Signatures / Key Rotation: If multiple
v1signatures are present (e.g., during webhook secret rotation), the current implementation only checks the last one because it overwrites thev1key in thepartsobject. It should collect allv1signatures and verify if any of them match.
Here is a secure and robust implementation that addresses all three issues.
async function verifyStripeSignature(
payload: string,
sigHeader: string,
secret: string,
): Promise<boolean> {
// Stripe-Signature header: "t=<ts>,v1=<hex hmac>[,v1=...]"
const v1Signatures: string[] = [];
let t = "";
for (const seg of sigHeader.split(",")) {
const trimmed = seg.trim();
const idx = trimmed.indexOf("=");
if (idx > 0) {
const key = trimmed.slice(0, idx);
const val = trimmed.slice(idx + 1);
if (key === "t") {
t = val;
} else if (key === "v1") {
v1Signatures.push(val);
}
}
}
if (!t || v1Signatures.length === 0) return false;
// Prevent replay attacks by verifying the timestamp is within tolerance (e.g., 5 minutes)
const timestamp = parseInt(t, 10);
if (isNaN(timestamp)) return false;
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > 300) return false;
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const mac = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(t + "." + payload));
const expected = [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, "0")).join("");
// Constant-time comparison against all v1 signatures to support key rotation
let matched = false;
for (const sig of v1Signatures) {
if (expected.length === sig.length) {
let diff = 0;
for (let i = 0; i < expected.length; i++) {
diff |= expected.charCodeAt(i) ^ sig.charCodeAt(i);
}
if (diff === 0) {
matched = true;
}
}
}
return matched;
}| function initBookCta() { | ||
| const btn = document.getElementById('book-consult-btn') as HTMLButtonElement | null; | ||
| if (!btn || btn.dataset.initialized === 'true') return; | ||
| btn.dataset.initialized = 'true'; | ||
| const status = document.getElementById('book-consult-status'); | ||
| if (!CHECKOUT_API_URL) { | ||
| btn.hidden = true; | ||
| return; | ||
| } |
There was a problem hiding this comment.
When CHECKOUT_API_URL is not configured, hiding only the button (btn.hidden = true) leaves the parent .book-cta container visible. Since .book-cta has a large vertical margin (margin: var(--space-2xl) 0;), this results in an awkward empty space in the layout. Hiding the entire container instead resolves this layout issue.
function initBookCta() {
const btn = document.getElementById('book-consult-btn') as HTMLButtonElement | null;
if (!btn || btn.dataset.initialized === 'true') return;
btn.dataset.initialized = 'true';
const status = document.getElementById('book-consult-status');
if (!CHECKOUT_API_URL) {
const container = btn.closest('.book-cta') as HTMLElement | null;
if (container) container.hidden = true;
return;
}
There was a problem hiding this comment.
Hey - I've found 2 issues, and left some high level feedback:
- In
verifyStripeSignature/handleStripeWebhook, consider enforcing a reasonablettimestamp window from theStripe-Signatureheader to mitigate replay attacks rather than only checking the HMAC. - The CTA button is hidden only client-side when
CHECKOUT_API_URLis unset; if you want to avoid briefly showing a non-functional control on slow JS, you could gate the markup on the same env condition server-side or render it progressively enhanced (e.g., as a link that upgrades to a button).
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- In `verifyStripeSignature`/`handleStripeWebhook`, consider enforcing a reasonable `t` timestamp window from the `Stripe-Signature` header to mitigate replay attacks rather than only checking the HMAC.
- The CTA button is hidden only client-side when `CHECKOUT_API_URL` is unset; if you want to avoid briefly showing a non-functional control on slow JS, you could gate the markup on the same env condition server-side or render it progressively enhanced (e.g., as a link that upgrades to a button).
## Individual Comments
### Comment 1
<location path="workers/consult-api/src/index.ts" line_range="326-335" />
<code_context>
}
}
+async function verifyStripeSignature(
+ payload: string,
+ sigHeader: string,
+ secret: string,
+): Promise<boolean> {
+ // Stripe-Signature header: "t=<ts>,v1=<hex hmac>[,v1=...]"
+ const parts: Record<string, string> = {};
+ for (const seg of sigHeader.split(',')) {
+ const idx = seg.indexOf('=');
+ if (idx > 0) parts[seg.slice(0, idx)] = seg.slice(idx + 1);
+ }
+ const t = parts.t;
+ const v1 = parts.v1;
+ if (!t || !v1) return false;
</code_context>
<issue_to_address>
**🚨 issue (security):** The Stripe signature verification should enforce a timestamp tolerance to mitigate replay attacks.
The HMAC check is correct, but `t` isn’t validated against a time window as Stripe recommends. This allows a valid payload/signature pair to be replayed indefinitely. Please parse `t` as an integer, compare it to `Date.now() / 1000`, and reject requests where the difference exceeds a configured threshold (e.g., 300 seconds).
</issue_to_address>
### Comment 2
<location path="workers/consult-api/src/index.ts" line_range="331-338" />
<code_context>
+ sigHeader: string,
+ secret: string,
+): Promise<boolean> {
+ // Stripe-Signature header: "t=<ts>,v1=<hex hmac>[,v1=...]"
+ const parts: Record<string, string> = {};
+ for (const seg of sigHeader.split(',')) {
+ const idx = seg.indexOf('=');
+ if (idx > 0) parts[seg.slice(0, idx)] = seg.slice(idx + 1);
+ }
+ const t = parts.t;
+ const v1 = parts.v1;
+ if (!t || !v1) return false;
+ const key = await crypto.subtle.importKey(
</code_context>
<issue_to_address>
**suggestion:** Signature parsing assumes a single v1 value and doesn’t robustly handle the full Stripe-Signature header format.
Stripe may include multiple `v1` signatures and whitespace around segments. This implementation overwrites earlier `v1` values and preserves any whitespace, so a valid signature might be ignored if it isn’t last or if formatting varies. Consider trimming each segment/key/value, storing all `v1` values, and verifying the computed HMAC against each until one matches, mirroring Stripe’s verification behavior.
Suggested implementation:
```typescript
// Stripe-Signature header: "t=<ts>,v1=<hex hmac>[,v1=...]"
let timestamp: string | null = null;
const v1Signatures: string[] = [];
for (const rawSeg of sigHeader.split(',')) {
const seg = rawSeg.trim();
if (!seg) continue;
const idx = seg.indexOf('=');
if (idx <= 0) continue;
const key = seg.slice(0, idx).trim();
const value = seg.slice(idx + 1).trim();
if (key === 't' && !timestamp) {
timestamp = value;
} else if (key === 'v1') {
v1Signatures.push(value);
}
}
if (!timestamp || v1Signatures.length === 0) return false;
```
```typescript
const mac = await crypto.subtle.sign(
'HMAC',
key,
new TextEncoder().encode(`${timestamp}.${payload}`),
);
const expected = [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, '0')).join('');
// Constant-time comparison against all provided v1 signatures.
for (const sig of v1Signatures) {
if (expected.length !== sig.length) continue;
let result = 0;
for (let i = 0; i < expected.length; i++) {
result |= expected.charCodeAt(i) ^ sig.charCodeAt(i);
}
if (result === 0) return true;
}
return false;
```
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
| async function verifyStripeSignature( | ||
| payload: string, | ||
| sigHeader: string, | ||
| secret: string, | ||
| ): Promise<boolean> { | ||
| // Stripe-Signature header: "t=<ts>,v1=<hex hmac>[,v1=...]" | ||
| const parts: Record<string, string> = {}; | ||
| for (const seg of sigHeader.split(',')) { | ||
| const idx = seg.indexOf('='); | ||
| if (idx > 0) parts[seg.slice(0, idx)] = seg.slice(idx + 1); |
There was a problem hiding this comment.
🚨 issue (security): The Stripe signature verification should enforce a timestamp tolerance to mitigate replay attacks.
The HMAC check is correct, but t isn’t validated against a time window as Stripe recommends. This allows a valid payload/signature pair to be replayed indefinitely. Please parse t as an integer, compare it to Date.now() / 1000, and reject requests where the difference exceeds a configured threshold (e.g., 300 seconds).
| // Stripe-Signature header: "t=<ts>,v1=<hex hmac>[,v1=...]" | ||
| const parts: Record<string, string> = {}; | ||
| for (const seg of sigHeader.split(',')) { | ||
| const idx = seg.indexOf('='); | ||
| if (idx > 0) parts[seg.slice(0, idx)] = seg.slice(idx + 1); | ||
| } | ||
| const t = parts.t; | ||
| const v1 = parts.v1; |
There was a problem hiding this comment.
suggestion: Signature parsing assumes a single v1 value and doesn’t robustly handle the full Stripe-Signature header format.
Stripe may include multiple v1 signatures and whitespace around segments. This implementation overwrites earlier v1 values and preserves any whitespace, so a valid signature might be ignored if it isn’t last or if formatting varies. Consider trimming each segment/key/value, storing all v1 values, and verifying the computed HMAC against each until one matches, mirroring Stripe’s verification behavior.
Suggested implementation:
// Stripe-Signature header: "t=<ts>,v1=<hex hmac>[,v1=...]"
let timestamp: string | null = null;
const v1Signatures: string[] = [];
for (const rawSeg of sigHeader.split(',')) {
const seg = rawSeg.trim();
if (!seg) continue;
const idx = seg.indexOf('=');
if (idx <= 0) continue;
const key = seg.slice(0, idx).trim();
const value = seg.slice(idx + 1).trim();
if (key === 't' && !timestamp) {
timestamp = value;
} else if (key === 'v1') {
v1Signatures.push(value);
}
}
if (!timestamp || v1Signatures.length === 0) return false; const mac = await crypto.subtle.sign(
'HMAC',
key,
new TextEncoder().encode(`${timestamp}.${payload}`),
);
const expected = [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, '0')).join('');
// Constant-time comparison against all provided v1 signatures.
for (const sig of v1Signatures) {
if (expected.length !== sig.length) continue;
let result = 0;
for (let i = 0; i < expected.length; i++) {
result |= expected.charCodeAt(i) ^ sig.charCodeAt(i);
}
if (result === 0) return true;
}
return false;There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e1a0f58ba8
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const parts: Record<string, string> = {}; | ||
| for (const seg of sigHeader.split(',')) { | ||
| const idx = seg.indexOf('='); | ||
| if (idx > 0) parts[seg.slice(0, idx)] = seg.slice(idx + 1); | ||
| } | ||
| const t = parts.t; | ||
| const v1 = parts.v1; |
There was a problem hiding this comment.
Validate against all Stripe v1 signatures
Stripe can send multiple v1= entries in Stripe-Signature (for example during webhook secret rotation), but this parser stores header parts in a map and keeps only one v1 value. If Stripe’s matching signature is not the retained one, valid webhook deliveries will be rejected with BAD_SIGNATURE (400), which can break checkout-completion processing until retries exhaust. Collect all v1 signatures and accept the event when any one matches the computed HMAC.
Useful? React with 👍 / 👎.
| const t = parts.t; | ||
| const v1 = parts.v1; | ||
| if (!t || !v1) return false; |
There was a problem hiding this comment.
Reject stale Stripe signatures
The webhook verifier checks HMAC correctness but never enforces timestamp recency from t, so any previously captured valid payload+signature can be replayed indefinitely and still return 200. Stripe’s own guidance includes a short tolerance window specifically to mitigate replay attacks; without that check, this endpoint cannot distinguish fresh deliveries from replays.
Useful? React with 👍 / 👎.
| ); | ||
| } | ||
| const sig = request.headers.get('Stripe-Signature'); | ||
| const payload = await request.text(); |
There was a problem hiding this comment.
Cap webhook body size before buffering payload
handleStripeWebhook reads the entire request body with await request.text() before any size validation, then computes HMAC over it. Because this endpoint is publicly reachable, oversized POSTs can force high memory/CPU use and trigger Worker resource-limit failures, which can block legitimate webhook deliveries under load. Add a strict content-length/body-size guard (as done for /api/consult) before buffering and verifying the payload.
Useful? React with 👍 / 👎.
More #16 code, per your call.
POST /api/stripe-webhook— verifies theStripe-Signaturevia Web Crypto HMAC-SHA256 (constant-time compare) and acknowledgescheckout.session.completed.503 NOT_CONFIGUREDuntilSTRIPE_WEBHOOK_SECRETis set;400on bad/missing signature. Added the env field.consult.astro"Book a consult" CTA — POSTs to/api/checkout, redirects to the returned Stripe URL. Degrades gracefully: shows a status message on503(checkout unconfigured) and hides itself if no consult API base is set.This connects the Organ III product to the gateway (a #16 acceptance item). What still requires you: provision Stripe (secret + price + webhook endpoint), then verify a live transaction and update
omega.json #11.Test plan
npm run lint/typecheck:strict— clean / 0 hintsnpm run build— CTA renders on/consultesbuild --bundle+ contract tests — clean / 6/6wrangler dev+ test keys.https://claude.ai/code/session_01PW9DnyVijUNmUJ4qMfgpHn
Generated by Claude Code
Summary by Sourcery
Add a Stripe webhook endpoint and wire the consult booking CTA to Stripe checkout.
New Features:
Enhancements: