Skip to content

feat: verify Stripe and Lemon Squeezy purchases on both webhook and redirect - #367

Open
adityaoberai wants to merge 1 commit into
mainfrom
feat-payments-verify-redirect
Open

adityaoberai wants to merge 1 commit into
mainfrom
feat-payments-verify-redirect

Conversation

@adityaoberai

Copy link
Copy Markdown
Contributor

What changed

Applies the same flow as the Dodo Payments templates (#366) to the four Stripe and Lemon Squeezy templates.

  • Webhooks now act only on the relevant events, then fetch the order or subscription from the provider's API. They provision only if the provider reports it as paid or active, so the event payload is no longer trusted.
  • New GET /success route. Checkout now returns here first. The route runs the same check and provisioning, then redirects to the app's successUrl. If either the webhook or the redirect fails, the other still provisions the user.
  • Concurrent runs are safe. Each order uses the provider's ID as its document ID, so a duplicate write is rejected with a 409 and treated as already done. For subscriptions, both paths apply the provider's current status, and the label updates do nothing when the label is already in that state.

Provider notes

  • Stripe: success_url includes {CHECKOUT_SESSION_ID}. Payments use the payment intent ID as the document ID because Stripe session IDs are longer than Appwrite's 36-character limit.
  • Lemon Squeezy: the redirect doesn't reliably include an order ID, and the API doesn't return checkout custom data. So the redirect carries a state parameter signed with HMAC, holding the user ID, the checkout email and the start time. /success matches orders or subscriptions made with that email since then. If no email was entered, or the buyer changed it during checkout, only the webhook provisions the user.
  • Lemon Squeezy payments: setup is now awaited before an order is written. It used to run in the background from the constructor.

Before merging

  • Stripe webhooks need these extra events: checkout.session.async_payment_succeeded for payments, and customer.subscription.updated for subscriptions.
  • The Lemon Squeezy subscriptions webhook now handles more lifecycle events, and the label stays while a subscription is cancelled or past_due.
  • These changes haven't run against live Stripe or Lemon Squeezy accounts yet. Each template needs a test-mode purchase.

🤖 Generated with Claude Code

…edirect

Webhooks now fetch the order or subscription from the provider instead of
trusting the event payload. Checkout returns to a new GET /success route
that runs the same check and provisioning before redirecting to
successUrl, so either path can provision the user. Deterministic order
document IDs and idempotent label updates make concurrent runs safe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@hansi-codes

hansi-codes Bot commented Oct 9, 2026

Copy link
Copy Markdown

🟡 Tier B · Needs changes before merging

The Lemon Squeezy redirect state can be replayed to claim later purchases for another user, and the verifier routing and subscription reconciliation have correctness gaps.

The Stripe and Lemon Squeezy templates now verify provider state through both webhook and checkout-return flows, then provision paid orders or synchronize subscription labels. The changes also add signed redirect state for Lemon Squeezy and make order writes idempotent using provider IDs.

Verdict New comments Fixed Still open
🛑 Changes requested 5 0 0
Finding Where
🟠 Prevent replayed state from matching future subscriptions node/subscriptions-with-lemon-squeezy/src/lemonsqueezy.js:86
🟠 Prevent replayed state from claiming future orders node/payments-with-lemon-squeezy/src/lemonsqueezy.js:85
🟡 Derive the verifier URL from the function host node/payments-with-stripe/src/main.js:94
🟡 Concurrent status syncs can leave a stale subscriber label node/subscriptions-with-stripe/src/main.js:37
🟡 Concurrent status syncs can leave a stale subscriber label node/subscriptions-with-lemon-squeezy/src/main.js:48
Fix with agent prompt
### Issue 1
node/subscriptions-with-lemon-squeezy/src/lemonsqueezy.js:86-89
**Prevent replayed state from matching future subscriptions**

This only requires a subscription to be created after `since`; a signed state token can be replayed later and match any future subscription with the same email and variant. Because the result is synchronized for the `userId` embedded in that old token, another user's purchase can grant subscriber access to the stale token's user.

### Issue 2
node/payments-with-lemon-squeezy/src/lemonsqueezy.js:85-90
**Prevent replayed state from claiming future orders**

This only requires an order to be created after `since`; a signed state token can be replayed later and match future orders with the same email and variant. Fulfillment assigns every match to the `userId` embedded in that old token, so a later buyer's order can be recorded for the stale token's user.

### Issue 3
node/payments-with-stripe/src/main.js:94-95
**Derive the verifier URL from the function host**

When `successUrl` points to the app on a separate origin, this resolves `/success` on the app host rather than on the Appwrite function, so the provider bypasses this verifier and the caller's requested success path is lost. The same default is used by the other three checkout handlers, so callers that omit `verifyUrl` do not get redirect-side verification.

### Issue 4
node/subscriptions-with-stripe/src/main.js:37-38
**Concurrent status syncs can leave a stale subscriber label**

If one invocation fetches `active`, the subscription is then canceled, and a second invocation removes the label before the first reaches this branch, the older invocation re-adds access after cancellation. The label helper is idempotent but does not order writes by provider status; the same race exists in the Lemon Squeezy subscription flow.

### Issue 5
node/subscriptions-with-lemon-squeezy/src/main.js:48-49
**Concurrent status syncs can leave a stale subscriber label**

If one invocation fetches `active`, the subscription is then canceled, and a second invocation removes the label before the first reaches this branch, the older invocation re-adds access after cancellation. The label helper is idempotent but does not order writes by provider status; the same race exists in the Stripe subscription flow.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.
📂 Walkthrough · 16
File Change
node/payments-with-lemon-squeezy/README.md, node/payments-with-lemon-squeezy/static/index.html Document the success route and pass a success URL from the demo checkout.
node/payments-with-lemon-squeezy/src/appwrite.js Use provider order IDs for document IDs and treat duplicate writes as already fulfilled.
node/payments-with-lemon-squeezy/src/lemonsqueezy.js Fetch and search provider orders, and sign and verify redirect state.
node/payments-with-lemon-squeezy/src/main.js Verify redirect and webhook orders against Lemon Squeezy before storing paid orders.
node/payments-with-stripe/README.md Document the success route, verifier URL, and provider-verified webhook flow.
node/payments-with-stripe/src/appwrite.js Use payment-intent IDs for order documents and handle duplicate writes.
node/payments-with-stripe/src/main.js Verify checkout sessions on redirects and relevant webhook events before storing orders.
node/payments-with-stripe/src/stripe.js Route Stripe checkout returns through verification and add session retrieval.
node/subscriptions-with-lemon-squeezy/README.md, node/subscriptions-with-lemon-squeezy/static/index.html Document the success route and pass a success URL from the demo checkout.
node/subscriptions-with-lemon-squeezy/src/appwrite.js Make subscriber-label changes idempotent.
node/subscriptions-with-lemon-squeezy/src/lemonsqueezy.js Fetch and search provider subscriptions, and sign and verify redirect state.
node/subscriptions-with-lemon-squeezy/src/main.js Synchronize labels from provider status on redirects and subscription lifecycle webhooks.
node/subscriptions-with-stripe/README.md Document the success route, verifier URL, and provider-verified subscription events.
node/subscriptions-with-stripe/src/appwrite.js Make subscriber-label changes idempotent.
node/subscriptions-with-stripe/src/main.js Synchronize subscriber labels from Stripe status on redirects and subscription webhooks.
node/subscriptions-with-stripe/src/stripe.js Route Stripe checkout returns through verification and add session and subscription retrieval.

Reviewed 5acfdd7 · Details · Comment @hansi-codes review to re-run, or mention @hansi-codes with a question.

@hansi-codes hansi-codes Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Tier B · 2 blocking findings to address. Summary

Comment on lines +86 to +89
return data.data.filter(
(subscription) =>
new Date(subscription.attributes.created_at) >= new Date(since)
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prevent replayed state from matching future subscriptions

This only requires a subscription to be created after since; a signed state token can be replayed later and match any future subscription with the same email and variant. Because the result is synchronized for the userId embedded in that old token, another user's purchase can grant subscriber access to the stale token's user.

Prompt To Fix With AI
This is a comment left during a code review.
Path: node/subscriptions-with-lemon-squeezy/src/lemonsqueezy.js
Line: 86-89

Comment:
**Prevent replayed state from matching future subscriptions**

This only requires a subscription to be created after `since`; a signed state token can be replayed later and match any future subscription with the same email and variant. Because the result is synchronized for the `userId` embedded in that old token, another user's purchase can grant subscriber access to the stale token's user.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

🟠 Major · security · Reply if this doesn't apply.

Comment on lines +85 to +90
return data.data.filter(
(order) =>
order.attributes.status === 'paid' &&
String(order.attributes.first_order_item.variant_id) ===
String(process.env.LEMON_SQUEEZY_VARIANT_ID) &&
new Date(order.attributes.created_at) >= new Date(since)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prevent replayed state from claiming future orders

This only requires an order to be created after since; a signed state token can be replayed later and match future orders with the same email and variant. Fulfillment assigns every match to the userId embedded in that old token, so a later buyer's order can be recorded for the stale token's user.

Prompt To Fix With AI
This is a comment left during a code review.
Path: node/payments-with-lemon-squeezy/src/lemonsqueezy.js
Line: 85-90

Comment:
**Prevent replayed state from claiming future orders**

This only requires an order to be created after `since`; a signed state token can be replayed later and match future orders with the same email and variant. Fulfillment assigns every match to the `userId` embedded in that old token, so a later buyer's order can be recorded for the stale token's user.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

🟠 Major · security · Reply if this doesn't apply.

Comment on lines +94 to 95
req.body?.verifyUrl ?? new URL('/success', successUrl).toString();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Derive the verifier URL from the function host

When successUrl points to the app on a separate origin, this resolves /success on the app host rather than on the Appwrite function, so the provider bypasses this verifier and the caller's requested success path is lost. The same default is used by the other three checkout handlers, so callers that omit verifyUrl do not get redirect-side verification.

Prompt To Fix With AI
This is a comment left during a code review.
Path: node/payments-with-stripe/src/main.js
Line: 94-95

Comment:
**Derive the verifier URL from the function host**

When `successUrl` points to the app on a separate origin, this resolves `/success` on the app host rather than on the Appwrite function, so the provider bypasses this verifier and the caller's requested success path is lost. The same default is used by the other three checkout handlers, so callers that omit `verifyUrl` do not get redirect-side verification.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

🟡 Minor · bug · Reply if this doesn't apply.

Comment on lines +37 to +38
if (StatusesWithAccess.includes(subscription.status)) {
await appwrite.createSubscription(userId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Concurrent status syncs can leave a stale subscriber label

If one invocation fetches active, the subscription is then canceled, and a second invocation removes the label before the first reaches this branch, the older invocation re-adds access after cancellation. The label helper is idempotent but does not order writes by provider status; the same race exists in the Lemon Squeezy subscription flow.

Prompt To Fix With AI
This is a comment left during a code review.
Path: node/subscriptions-with-stripe/src/main.js
Line: 37-38

Comment:
**Concurrent status syncs can leave a stale subscriber label**

If one invocation fetches `active`, the subscription is then canceled, and a second invocation removes the label before the first reaches this branch, the older invocation re-adds access after cancellation. The label helper is idempotent but does not order writes by provider status; the same race exists in the Lemon Squeezy subscription flow.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

🟡 Minor · concurrency · Reply if this doesn't apply.

Comment on lines +48 to +49
if (StatusesWithAccess.includes(status)) {
await appwrite.createSubscription(userId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Concurrent status syncs can leave a stale subscriber label

If one invocation fetches active, the subscription is then canceled, and a second invocation removes the label before the first reaches this branch, the older invocation re-adds access after cancellation. The label helper is idempotent but does not order writes by provider status; the same race exists in the Stripe subscription flow.

Prompt To Fix With AI
This is a comment left during a code review.
Path: node/subscriptions-with-lemon-squeezy/src/main.js
Line: 48-49

Comment:
**Concurrent status syncs can leave a stale subscriber label**

If one invocation fetches `active`, the subscription is then canceled, and a second invocation removes the label before the first reaches this branch, the older invocation re-adds access after cancellation. The label helper is idempotent but does not order writes by provider status; the same race exists in the Stripe subscription flow.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

🟡 Minor · concurrency · Reply if this doesn't apply.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant