From d24c7dea234b2d2e5c968b6437873474d076da04 Mon Sep 17 00:00:00 2001 From: adityaoberai Date: Fri, 9 Oct 2026 01:55:26 +0530 Subject: [PATCH 1/4] feat: add Payments with Dodo Payments template One-time checkout through a Dodo Payments checkout session. The payment.succeeded webhook is verified with Standard Webhooks and stores the order as a row in an Appwrite table, using the payment ID as the row ID so repeated deliveries don't store it twice. The database and table are created on the first order, with the columns inline in createTable. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TaBGMrHMJFCb9gAU8rQb6A --- node/payments-with-dodo-payments/.gitignore | 133 ++++++++++ .../.prettierrc.json | 6 + node/payments-with-dodo-payments/README.md | 144 +++++++++++ .../package-lock.json | 109 ++++++++ node/payments-with-dodo-payments/package.json | 20 ++ .../src/appwrite.js | 92 +++++++ .../src/dodopayments.js | 66 +++++ node/payments-with-dodo-payments/src/main.js | 108 ++++++++ node/payments-with-dodo-payments/src/utils.js | 43 ++++ .../static/index.html | 240 ++++++++++++++++++ 10 files changed, 961 insertions(+) create mode 100644 node/payments-with-dodo-payments/.gitignore create mode 100644 node/payments-with-dodo-payments/.prettierrc.json create mode 100644 node/payments-with-dodo-payments/README.md create mode 100644 node/payments-with-dodo-payments/package-lock.json create mode 100644 node/payments-with-dodo-payments/package.json create mode 100644 node/payments-with-dodo-payments/src/appwrite.js create mode 100644 node/payments-with-dodo-payments/src/dodopayments.js create mode 100644 node/payments-with-dodo-payments/src/main.js create mode 100644 node/payments-with-dodo-payments/src/utils.js create mode 100644 node/payments-with-dodo-payments/static/index.html diff --git a/node/payments-with-dodo-payments/.gitignore b/node/payments-with-dodo-payments/.gitignore new file mode 100644 index 00000000..46afb6b3 --- /dev/null +++ b/node/payments-with-dodo-payments/.gitignore @@ -0,0 +1,133 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# Directory used by Appwrite CLI for local development +.appwrite \ No newline at end of file diff --git a/node/payments-with-dodo-payments/.prettierrc.json b/node/payments-with-dodo-payments/.prettierrc.json new file mode 100644 index 00000000..0a725205 --- /dev/null +++ b/node/payments-with-dodo-payments/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "trailingComma": "es5", + "tabWidth": 2, + "semi": true, + "singleQuote": true +} diff --git a/node/payments-with-dodo-payments/README.md b/node/payments-with-dodo-payments/README.md new file mode 100644 index 00000000..afc4e8fb --- /dev/null +++ b/node/payments-with-dodo-payments/README.md @@ -0,0 +1,144 @@ +# 💳 Node.js Payments with Dodo Payments Function + +Receive one-time payments with Dodo Payments and store paid orders in an Appwrite table. + +## 🧰 Usage + +### `GET /` + +Serves a demo page where a user can register, create a checkout and see their paid orders. + +### `POST /checkout` + +This endpoint creates a [Dodo Payments checkout session](https://docs.dodopayments.com/developer-resources/checkout-session) for the product in `DODO_PAYMENTS_PRODUCT_ID`. The user ID is fetched from the headers of the request and stored in the checkout metadata. If the user ID is not found or the checkout session can't be created, the request is redirected to the failure URL. + +**Parameters** + +| Name | Description | Location | Type | Sample Value | +| ------------------ | ------------------------------------------------------------------------------------------------- | -------- | ------------------ | --------------------------- | +| x-appwrite-user-id | User ID from Appwrite. | Header | String | 642...7cd | +| Content-Type | The content type of the request body | Header | `application/json` | N/A | +| successUrl | The URL to return to after checkout. Defaults to the function domain. | Body | String | https://example.com/success | +| failureUrl | The URL to redirect to after a cancelled or failed checkout. Defaults to the function domain. | Body | String | https://example.com/failure | +| email | Optional. Prefills the customer's email address in the checkout. Test mode sends real emails too. | Body | String | jane@example.com | +| name | Optional. Prefills the customer's name in the checkout when `email` is also set. | Body | String | Jane Doe | + +**Response** + +Sample `303` Response: + +The response is a redirect to the Dodo Payments checkout URL, or to the failure URL if an error occurs. + +```text +Location: https://test.checkout.dodopayments.com/session/cks_Gi6KGJ2zFJo9rq9Ukifwa +``` + +```text +Location: https://example.com/failure +``` + +### `POST /webhook` + +This endpoint receives Dodo Payments webhooks and handles the `payment.succeeded` event. It verifies the [Standard Webhooks](https://www.standardwebhooks.com/) signature in the `webhook-id`, `webhook-signature` and `webhook-timestamp` headers. If the verification fails, a `401` response is sent. + +On `payment.succeeded`, the function creates the orders database and table if they don't exist yet, then creates a row for the order. The row ID is the Dodo Payments payment ID, so a repeated delivery of the same event doesn't store the order twice. Only the user who paid can read the row. + +**Parameters** + +| Name | Description | Location | Type | Sample Value | +| ----------------- | ----------------------------------- | -------- | ------ | ------------------------------------------------------------------------------------ | +| None | Webhook payload from Dodo Payments. | Body | Object | [See Dodo Payments docs](https://docs.dodopayments.com/developer-resources/webhooks) | +| webhook-id | Unique ID of the webhook message. | Header | String | `msg_2Lh9...` | +| webhook-signature | Signature from Dodo Payments. | Header | String | [See Dodo Payments docs](https://docs.dodopayments.com/developer-resources/webhooks) | +| webhook-timestamp | Unix timestamp of the delivery. | Header | String | `1759938600` | + +**Response** + +Sample `200` Response: + +In case of the `payment.succeeded` event, a row for the order is created in the Appwrite table. Other events are acknowledged and ignored. + +```json +{ "success": true } +``` + +Sample `401` Response: + +```json +{ "success": false } +``` + +## ⚙️ Configuration + +| Setting | Value | +| ----------------- | -------------------------------------------------------------- | +| Runtime | Node (22) | +| Entrypoint | `src/main.js` | +| Build Commands | `npm install` | +| Permissions | `any` | +| Timeout (Seconds) | 15 | +| Scopes | `databases.write`, `tables.read`, `tables.write`, `rows.write` | + +> If using a demo web app to create order, make sure to add your function domain as a web platform to your Appwrite project. Doing this fixes CORS errors and allows proper functionality. + +> After deploying, add `https:///webhook` as an endpoint in the Dodo Payments dashboard under **Developer > Webhooks** and select the `payment.succeeded` event. An endpoint with no events selected receives every event type. Copy the endpoint's signing secret into `DODO_PAYMENTS_WEBHOOK_KEY`. + +## 🔒 Environment Variables + +### DODO_PAYMENTS_API_KEY + +API key for sending requests to the Dodo Payments API. The key needs write access to create checkout sessions. + +| Question | Answer | +| ------------- | -------------------------------------------------------------------------------------------------- | +| Required | Yes | +| Sample Value | `abcd...` | +| Documentation | [Dodo Payments: API keys](https://docs.dodopayments.com/api-reference/introduction#authentication) | + +### DODO_PAYMENTS_WEBHOOK_KEY + +Signing secret of the webhook endpoint, used to verify that webhooks come from Dodo Payments. + +| Question | Answer | +| ------------- | ------------------------------------------------------------------------------------- | +| Required | Yes | +| Sample Value | `whsec_MfKQ...` | +| Documentation | [Dodo Payments: Webhooks](https://docs.dodopayments.com/developer-resources/webhooks) | + +### DODO_PAYMENTS_PRODUCT_ID + +ID of the one-time product to sell. + +| Question | Answer | +| ------------- | -------------------------------------------------------------------------- | +| Required | Yes | +| Sample Value | `pdt_0NWa...` | +| Documentation | [Dodo Payments: Products](https://docs.dodopayments.com/features/products) | + +### DODO_PAYMENTS_ENVIRONMENT + +Dodo Payments environment to use, either `test_mode` or `live_mode`. Defaults to `test_mode`. Test mode and live mode have separate API keys, products and webhooks. + +| Question | Answer | +| ------------- | ----------------------------------------------------------------------------------------------------------- | +| Required | No | +| Sample Value | `test_mode` | +| Documentation | [Dodo Payments: Test mode vs live mode](https://docs.dodopayments.com/miscellaneous/test-mode-vs-live-mode) | + +### APPWRITE_DATABASE_ID + +The ID of the database to store the orders. Defaults to `orders`. + +| Question | Answer | +| ------------ | -------- | +| Required | No | +| Sample Value | `orders` | + +### APPWRITE_TABLE_ID + +The ID of the table to store the orders. Defaults to `orders`. + +| Question | Answer | +| ------------ | -------- | +| Required | No | +| Sample Value | `orders` | diff --git a/node/payments-with-dodo-payments/package-lock.json b/node/payments-with-dodo-payments/package-lock.json new file mode 100644 index 00000000..8d52f357 --- /dev/null +++ b/node/payments-with-dodo-payments/package-lock.json @@ -0,0 +1,109 @@ +{ + "name": "payments-with-dodo-payments", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "payments-with-dodo-payments", + "version": "1.0.0", + "dependencies": { + "dodopayments": "^2.54.0", + "node-appwrite": "^29.1.0" + }, + "devDependencies": { + "prettier": "^3.9.9" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/dodopayments": { + "version": "2.54.0", + "resolved": "https://registry.npmjs.org/dodopayments/-/dodopayments-2.54.0.tgz", + "integrity": "sha512-qszw1oaXxk4lyvj5XT/Mhpe6UPBhzjTZDzM86nuSRGZVrwltFncW6VhVNmO44q8m59Eb/8qS7inCQk36fc5xnA==", + "license": "Apache-2.0", + "dependencies": { + "standardwebhooks": "^1.0.0" + }, + "bin": { + "dodopayments": "bin/cli" + } + }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/node-appwrite": { + "version": "29.1.0", + "resolved": "https://registry.npmjs.org/node-appwrite/-/node-appwrite-29.1.0.tgz", + "integrity": "sha512-/pRQEqTPFozg+2RFdtqqGY4cuqBkp8JK8PEuouhpm/f8UJPVk5AtsM+bSoEyIemJBcIkRNr2fwdeiqYxuOkUwg==", + "license": "BSD-3-Clause", + "dependencies": { + "json-bigint": "1.0.0", + "undici": "^6.28.1" + } + }, + "node_modules/prettier": { + "version": "3.9.9", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.9.tgz", + "integrity": "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/standardwebhooks": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.1.1.tgz", + "integrity": "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, + "node_modules/undici": { + "version": "6.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.29.0.tgz", + "integrity": "sha512-R+RODBqp6i2pPflGdq+xIOUkl+RNfGgHwoinecKu/JCuf2uO06cOKoDbI2P7Dn6KcswdKwrczbU6IYJ6K8X+wg==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + } + } +} diff --git a/node/payments-with-dodo-payments/package.json b/node/payments-with-dodo-payments/package.json new file mode 100644 index 00000000..e69dbba7 --- /dev/null +++ b/node/payments-with-dodo-payments/package.json @@ -0,0 +1,20 @@ +{ + "name": "payments-with-dodo-payments", + "version": "1.0.0", + "description": "", + "main": "src/main.js", + "type": "module", + "scripts": { + "format": "prettier --write ." + }, + "dependencies": { + "dodopayments": "^2.54.0", + "node-appwrite": "^29.1.0" + }, + "devDependencies": { + "prettier": "^3.9.9" + }, + "engines": { + "node": ">=20.0.0" + } +} diff --git a/node/payments-with-dodo-payments/src/appwrite.js b/node/payments-with-dodo-payments/src/appwrite.js new file mode 100644 index 00000000..9fab0099 --- /dev/null +++ b/node/payments-with-dodo-payments/src/appwrite.js @@ -0,0 +1,92 @@ +import { Client, Permission, Role, TablesDB } from 'node-appwrite'; + +class AppwriteService { + constructor(apiKey) { + const client = new Client(); + client + .setEndpoint(process.env.APPWRITE_FUNCTION_API_ENDPOINT) + .setProject(process.env.APPWRITE_FUNCTION_PROJECT_ID) + .setKey(apiKey); + + this.tablesDB = new TablesDB(client); + } + + /** + * Creates the orders database and table if they don't exist yet + * @param {string} databaseId + * @param {string} tableId + * @returns {Promise} + */ + async setup(databaseId, tableId) { + if (await this.doesOrdersTableExist(databaseId, tableId)) { + return; + } + + await this.setupOrdersTable(databaseId, tableId); + } + + async doesOrdersTableExist(databaseId, tableId) { + try { + await this.tablesDB.getTable({ databaseId, tableId }); + return true; + } catch (err) { + if (err.code !== 404) throw err; + return false; + } + } + + async setupOrdersTable(databaseId, tableId) { + try { + await this.tablesDB.create({ databaseId, name: 'Orders Database' }); + } catch (err) { + if (err.code !== 409) throw err; + } + + // Columns passed to createTable are available as soon as it returns, so + // rows can be written right away. + try { + await this.tablesDB.createTable({ + databaseId, + tableId, + name: 'Orders', + rowSecurity: true, + columns: [ + { key: 'userId', type: 'varchar', size: 255, required: true }, + { key: 'orderId', type: 'varchar', size: 255, required: true }, + ], + }); + } catch (err) { + if (err.code !== 409) throw err; + } + } + + /** + * Stores a paid order, using the order ID as the row ID so a repeated + * webhook delivery can't store the same order twice + * @param {string} databaseId + * @param {string} tableId + * @param {string} userId + * @param {string} orderId + * @returns {Promise} false if the order was already stored + */ + async createOrder(databaseId, tableId, userId, orderId) { + try { + await this.tablesDB.createRow({ + databaseId, + tableId, + rowId: orderId, + data: { + userId, + orderId, + }, + permissions: [Permission.read(Role.user(userId))], + }); + return true; + } catch (err) { + if (err.code !== 409) throw err; + return false; + } + } +} + +export default AppwriteService; diff --git a/node/payments-with-dodo-payments/src/dodopayments.js b/node/payments-with-dodo-payments/src/dodopayments.js new file mode 100644 index 00000000..eeddddad --- /dev/null +++ b/node/payments-with-dodo-payments/src/dodopayments.js @@ -0,0 +1,66 @@ +import DodoPayments from 'dodopayments'; + +class DodoPaymentsService { + constructor() { + this.client = new DodoPayments({ + bearerToken: process.env.DODO_PAYMENTS_API_KEY, + webhookKey: process.env.DODO_PAYMENTS_WEBHOOK_KEY, + // The SDK defaults to live mode, so fall back to test mode explicitly. + environment: process.env.DODO_PAYMENTS_ENVIRONMENT || 'test_mode', + }); + } + + async createCheckout( + context, + userId, + userEmail, + userName, + successUrl, + failureUrl + ) { + try { + return await this.client.checkoutSessions.create({ + product_cart: [ + { product_id: process.env.DODO_PAYMENTS_PRODUCT_ID, quantity: 1 }, + ], + // Test mode sends real emails, so only prefill an address the user entered. + customer: userEmail ? { email: userEmail, name: userName } : undefined, + metadata: { + user_id: userId, + }, + return_url: successUrl, + cancel_url: failureUrl, + }); + } catch (err) { + context.error(err); + return null; + } + } + + /** + * Verifies the Standard Webhooks signature of a Dodo Payments webhook + * @param {*} context + * @returns {*} The verified event, or null if verification failed + */ + validateWebhook(context) { + try { + const { headers } = context.req; + // unwrap() only verifies the signature when headers are passed. + const event = this.client.webhooks.unwrap(context.req.bodyText, { + headers: { + 'webhook-id': headers['webhook-id'], + 'webhook-signature': headers['webhook-signature'], + 'webhook-timestamp': headers['webhook-timestamp'], + }, + }); + + context.log('Webhook signature is valid.'); + return event; + } catch (err) { + context.error(err); + return null; + } + } +} + +export default DodoPaymentsService; diff --git a/node/payments-with-dodo-payments/src/main.js b/node/payments-with-dodo-payments/src/main.js new file mode 100644 index 00000000..029291c5 --- /dev/null +++ b/node/payments-with-dodo-payments/src/main.js @@ -0,0 +1,108 @@ +import AppwriteService from './appwrite.js'; +import { getStaticFile, interpolate, throwIfMissing } from './utils.js'; +import DodoPaymentsService from './dodopayments.js'; + +export default async (context) => { + const { req, res, log, error } = context; + + throwIfMissing(process.env, [ + 'DODO_PAYMENTS_API_KEY', + 'DODO_PAYMENTS_WEBHOOK_KEY', + 'DODO_PAYMENTS_PRODUCT_ID', + ]); + + const databaseId = process.env.APPWRITE_DATABASE_ID || 'orders'; + const tableId = process.env.APPWRITE_TABLE_ID || 'orders'; + + if (req.method === 'GET') { + const html = interpolate(getStaticFile('index.html'), { + APPWRITE_FUNCTION_API_ENDPOINT: + process.env.APPWRITE_FUNCTION_API_ENDPOINT, + APPWRITE_FUNCTION_PROJECT_ID: process.env.APPWRITE_FUNCTION_PROJECT_ID, + APPWRITE_FUNCTION_ID: process.env.APPWRITE_FUNCTION_ID, + APPWRITE_DATABASE_ID: databaseId, + APPWRITE_TABLE_ID: tableId, + }); + + return res.text(html, 200, { 'Content-Type': 'text/html; charset=utf-8' }); + } + + const appwrite = new AppwriteService(req.headers['x-appwrite-key']); + const dodopayments = new DodoPaymentsService(); + + switch (req.path) { + case '/checkout': + const body = req.bodyJson; + const fallbackUrl = req.scheme + '://' + req.headers['host'] + '/'; + const successUrl = body.successUrl ?? fallbackUrl; + const failureUrl = body.failureUrl ?? fallbackUrl; + + const userId = req.headers['x-appwrite-user-id']; + + if (!userId) { + error('User ID not found in request.'); + return res.redirect(failureUrl, 303); + } + + const checkout = await dodopayments.createCheckout( + context, + userId, + body.email, + body.name, + successUrl, + failureUrl + ); + if (!checkout?.checkout_url) { + error('Failed to create Dodo Payments checkout.'); + return res.redirect(failureUrl, 303); + } + + log( + `Created Dodo Payments checkout ${checkout.session_id} for user ${userId}.` + ); + return res.redirect(checkout.checkout_url, 303); + + case '/webhook': + const event = dodopayments.validateWebhook(context); + if (!event) { + return res.json({ success: false }, 401); + } + + if (event.type !== 'payment.succeeded') { + log(`Ignored Dodo Payments event ${event.type}.`); + return res.json({ success: true }); + } + + const payment = event.data; + const orderUserId = payment.metadata?.user_id; + const orderId = payment.payment_id; + + // Dodo Payments retries any non-2xx response, and a payment without a + // user ID will never succeed, so acknowledge it instead. + if (!orderUserId) { + error(`Payment ${orderId} has no user_id in its metadata.`); + return res.json({ success: true }); + } + + await appwrite.setup(databaseId, tableId); + + const created = await appwrite.createOrder( + databaseId, + tableId, + orderUserId, + orderId + ); + if (!created) { + log(`Order ${orderId} was already stored.`); + return res.json({ success: true }); + } + + log( + `Created order row for user ${orderUserId} with Dodo Payments payment ID ${orderId}.` + ); + return res.json({ success: true }); + + default: + return res.text('Not Found', 404); + } +}; diff --git a/node/payments-with-dodo-payments/src/utils.js b/node/payments-with-dodo-payments/src/utils.js new file mode 100644 index 00000000..1a8951bd --- /dev/null +++ b/node/payments-with-dodo-payments/src/utils.js @@ -0,0 +1,43 @@ +import path from 'path'; +import { fileURLToPath } from 'url'; +import fs from 'fs'; + +/** + * Throws an error if any of the keys are missing from the object + * @param {*} obj + * @param {string[]} keys + * @throws {Error} + */ +export function throwIfMissing(obj, keys) { + const missing = []; + for (let key of keys) { + if (!(key in obj) || !obj[key]) { + missing.push(key); + } + } + if (missing.length > 0) { + throw new Error(`Missing required fields: ${missing.join(', ')}`); + } +} + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const staticFolder = path.join(__dirname, '../static'); + +/** + * Returns the contents of a file in the static folder + * @param {string} fileName + * @returns {string} Contents of static/{fileName} + */ +export function getStaticFile(fileName) { + return fs.readFileSync(path.join(staticFolder, fileName)).toString(); +} + +/** + * @param {string} template + * @param {Record} values + * @returns {string} + */ +export function interpolate(template, values) { + return template.replace(/{{([^}]+)}}/g, (_, key) => values[key] || ''); +} diff --git a/node/payments-with-dodo-payments/static/index.html b/node/payments-with-dodo-payments/static/index.html new file mode 100644 index 00000000..6ba1fd84 --- /dev/null +++ b/node/payments-with-dodo-payments/static/index.html @@ -0,0 +1,240 @@ + + + + + + + Payments with Dodo Payments + + + + + + + +
+
+
+
+

Payments with Dodo Payments

+ +
+

+ Use this demo to create a Dodo Payments payment. Once paid, a row is + created in an Appwrite table. You can then use this to give access + to video courses, SaaS payments, or any other digital goods and + services. +

+
+
+
+
+ + + +
+
+
+ + + + + From a600e38e4394efc80cbcba7c92f1efd59dbdfdba Mon Sep 17 00:00:00 2001 From: adityaoberai Date: Fri, 9 Oct 2026 01:55:27 +0530 Subject: [PATCH 2/4] feat: add Subscriptions with Dodo Payments template Subscription checkout through a Dodo Payments checkout session. Every subscription.* webhook sets the subscriber label from the subscription's current status rather than the event type: active and past_due keep access, every other status removes it. A late or out-of-order event therefore can't give access back after a cancellation. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TaBGMrHMJFCb9gAU8rQb6A --- .../.gitignore | 133 ++++++++++++ .../.prettierrc.json | 6 + .../README.md | 133 ++++++++++++ .../package-lock.json | 109 ++++++++++ .../package.json | 20 ++ .../src/appwrite.js | 45 ++++ .../src/dodopayments.js | 66 ++++++ .../src/main.js | 106 ++++++++++ .../src/utils.js | 43 ++++ .../static/index.html | 199 ++++++++++++++++++ 10 files changed, 860 insertions(+) create mode 100644 node/subscriptions-with-dodo-payments/.gitignore create mode 100644 node/subscriptions-with-dodo-payments/.prettierrc.json create mode 100644 node/subscriptions-with-dodo-payments/README.md create mode 100644 node/subscriptions-with-dodo-payments/package-lock.json create mode 100644 node/subscriptions-with-dodo-payments/package.json create mode 100644 node/subscriptions-with-dodo-payments/src/appwrite.js create mode 100644 node/subscriptions-with-dodo-payments/src/dodopayments.js create mode 100644 node/subscriptions-with-dodo-payments/src/main.js create mode 100644 node/subscriptions-with-dodo-payments/src/utils.js create mode 100644 node/subscriptions-with-dodo-payments/static/index.html diff --git a/node/subscriptions-with-dodo-payments/.gitignore b/node/subscriptions-with-dodo-payments/.gitignore new file mode 100644 index 00000000..46afb6b3 --- /dev/null +++ b/node/subscriptions-with-dodo-payments/.gitignore @@ -0,0 +1,133 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# Directory used by Appwrite CLI for local development +.appwrite \ No newline at end of file diff --git a/node/subscriptions-with-dodo-payments/.prettierrc.json b/node/subscriptions-with-dodo-payments/.prettierrc.json new file mode 100644 index 00000000..0a725205 --- /dev/null +++ b/node/subscriptions-with-dodo-payments/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "trailingComma": "es5", + "tabWidth": 2, + "semi": true, + "singleQuote": true +} diff --git a/node/subscriptions-with-dodo-payments/README.md b/node/subscriptions-with-dodo-payments/README.md new file mode 100644 index 00000000..e5225bea --- /dev/null +++ b/node/subscriptions-with-dodo-payments/README.md @@ -0,0 +1,133 @@ +# 💳 Node.js Subscriptions with Dodo Payments Function + +Receive recurring payments with Dodo Payments and grant subscribers extra permissions. + +## 🧰 Usage + +### `GET /` + +Serves a demo page where a user can register, subscribe and see their subscription status. + +### `POST /subscribe` + +This endpoint creates a [Dodo Payments checkout session](https://docs.dodopayments.com/developer-resources/checkout-session) for the subscription product in `DODO_PAYMENTS_PRODUCT_ID`. The user ID is fetched from the headers of the request and stored in the checkout metadata, which Dodo Payments copies to the subscription. If the user ID is not found or the checkout session can't be created, the request is redirected to the failure URL. + +**Parameters** + +| Name | Description | Location | Type | Sample Value | +| ------------------ | ------------------------------------------------------------------------------------------------- | -------- | ------------------ | --------------------------- | +| x-appwrite-user-id | User ID from Appwrite. | Header | String | 642...7cd | +| Content-Type | The content type of the request body | Header | `application/json` | N/A | +| successUrl | The URL to return to after checkout. Defaults to the function domain. | Body | String | https://example.com/success | +| failureUrl | The URL to redirect to after a cancelled or failed checkout. Defaults to the function domain. | Body | String | https://example.com/failure | +| email | Optional. Prefills the customer's email address in the checkout. Test mode sends real emails too. | Body | String | jane@example.com | +| name | Optional. Prefills the customer's name in the checkout when `email` is also set. | Body | String | Jane Doe | + +**Response** + +Sample `303` Response: + +The response is a redirect to the Dodo Payments checkout URL, or to the failure URL if an error occurs. + +```text +Location: https://test.checkout.dodopayments.com/session/cks_Gi6KGJ2zFJo9rq9Ukifwa +``` + +```text +Location: https://example.com/failure +``` + +### `POST /webhook` + +This endpoint receives Dodo Payments webhooks and handles every `subscription.*` event. It verifies the [Standard Webhooks](https://www.standardwebhooks.com/) signature in the `webhook-id`, `webhook-signature` and `webhook-timestamp` headers. If the verification fails, a `401` response is sent. + +Webhooks can arrive late, twice or out of order, but each one carries the subscription's current status. The function decides from that status rather than the event type, so a delayed event can't give access back after a cancellation: + +| Subscription status | Effect | +| ---------------------------------------------------------------- | -------------------------------------------- | +| `active`, `past_due` | Adds the `subscriber` label to the user | +| `on_hold`, `paused`, `cancelled`, `expired`, `failed`, `pending` | Removes the `subscriber` label from the user | + +`past_due` is the grace period while Dodo Payments retries a failed renewal, so the customer keeps access until the subscription moves to `on_hold` or `cancelled`. See the [subscription integration guide](https://docs.dodopayments.com/developer-resources/subscription-integration-guide) for the full lifecycle. + +**Parameters** + +| Name | Description | Location | Type | Sample Value | +| ----------------- | ----------------------------------- | -------- | ------ | ------------------------------------------------------------------------------------ | +| None | Webhook payload from Dodo Payments. | Body | Object | [See Dodo Payments docs](https://docs.dodopayments.com/developer-resources/webhooks) | +| webhook-id | Unique ID of the webhook message. | Header | String | `msg_2Lh9...` | +| webhook-signature | Signature from Dodo Payments. | Header | String | [See Dodo Payments docs](https://docs.dodopayments.com/developer-resources/webhooks) | +| webhook-timestamp | Unix timestamp of the delivery. | Header | String | `1759938600` | + +**Response** + +Sample `200` Response: + +In case of a `subscription.*` event, the `subscriber` label is added to or removed from the user. Other events are acknowledged and ignored. + +```json +{ "success": true } +``` + +Sample `401` Response: + +```json +{ "success": false } +``` + +## ⚙️ Configuration + +| Setting | Value | +| ----------------- | --------------------------- | +| Runtime | Node (22) | +| Entrypoint | `src/main.js` | +| Build Commands | `npm install` | +| Permissions | `any` | +| Timeout (Seconds) | 15 | +| Scopes | `users.read`, `users.write` | + +> If using a demo web app to subscribe, make sure to add your function domain as a web platform to your Appwrite project. Doing this fixes CORS errors and allows proper functionality. + +> After deploying, add `https:///webhook` as an endpoint in the Dodo Payments dashboard under **Developer > Webhooks** and select the subscription events. An endpoint with no events selected receives every event type. Copy the endpoint's signing secret into `DODO_PAYMENTS_WEBHOOK_KEY`. + +## 🔒 Environment Variables + +### DODO_PAYMENTS_API_KEY + +API key for sending requests to the Dodo Payments API. The key needs write access to create checkout sessions. + +| Question | Answer | +| ------------- | -------------------------------------------------------------------------------------------------- | +| Required | Yes | +| Sample Value | `abcd...` | +| Documentation | [Dodo Payments: API keys](https://docs.dodopayments.com/api-reference/introduction#authentication) | + +### DODO_PAYMENTS_WEBHOOK_KEY + +Signing secret of the webhook endpoint, used to verify that webhooks come from Dodo Payments. + +| Question | Answer | +| ------------- | ------------------------------------------------------------------------------------- | +| Required | Yes | +| Sample Value | `whsec_MfKQ...` | +| Documentation | [Dodo Payments: Webhooks](https://docs.dodopayments.com/developer-resources/webhooks) | + +### DODO_PAYMENTS_PRODUCT_ID + +ID of the subscription product to sell. + +| Question | Answer | +| ------------- | ---------------------------------------------------------------------------------------------------------------- | +| Required | Yes | +| Sample Value | `pdt_0NWa...` | +| Documentation | [Dodo Payments: Subscriptions](https://docs.dodopayments.com/developer-resources/subscription-integration-guide) | + +### DODO_PAYMENTS_ENVIRONMENT + +Dodo Payments environment to use, either `test_mode` or `live_mode`. Defaults to `test_mode`. Test mode and live mode have separate API keys, products and webhooks. + +| Question | Answer | +| ------------- | ----------------------------------------------------------------------------------------------------------- | +| Required | No | +| Sample Value | `test_mode` | +| Documentation | [Dodo Payments: Test mode vs live mode](https://docs.dodopayments.com/miscellaneous/test-mode-vs-live-mode) | diff --git a/node/subscriptions-with-dodo-payments/package-lock.json b/node/subscriptions-with-dodo-payments/package-lock.json new file mode 100644 index 00000000..6b7d336b --- /dev/null +++ b/node/subscriptions-with-dodo-payments/package-lock.json @@ -0,0 +1,109 @@ +{ + "name": "subscriptions-with-dodo-payments", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "subscriptions-with-dodo-payments", + "version": "1.0.0", + "dependencies": { + "dodopayments": "^2.54.0", + "node-appwrite": "^29.1.0" + }, + "devDependencies": { + "prettier": "^3.9.9" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/dodopayments": { + "version": "2.54.0", + "resolved": "https://registry.npmjs.org/dodopayments/-/dodopayments-2.54.0.tgz", + "integrity": "sha512-qszw1oaXxk4lyvj5XT/Mhpe6UPBhzjTZDzM86nuSRGZVrwltFncW6VhVNmO44q8m59Eb/8qS7inCQk36fc5xnA==", + "license": "Apache-2.0", + "dependencies": { + "standardwebhooks": "^1.0.0" + }, + "bin": { + "dodopayments": "bin/cli" + } + }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/node-appwrite": { + "version": "29.1.0", + "resolved": "https://registry.npmjs.org/node-appwrite/-/node-appwrite-29.1.0.tgz", + "integrity": "sha512-/pRQEqTPFozg+2RFdtqqGY4cuqBkp8JK8PEuouhpm/f8UJPVk5AtsM+bSoEyIemJBcIkRNr2fwdeiqYxuOkUwg==", + "license": "BSD-3-Clause", + "dependencies": { + "json-bigint": "1.0.0", + "undici": "^6.28.1" + } + }, + "node_modules/prettier": { + "version": "3.9.9", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.9.tgz", + "integrity": "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/standardwebhooks": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.1.1.tgz", + "integrity": "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, + "node_modules/undici": { + "version": "6.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.29.0.tgz", + "integrity": "sha512-R+RODBqp6i2pPflGdq+xIOUkl+RNfGgHwoinecKu/JCuf2uO06cOKoDbI2P7Dn6KcswdKwrczbU6IYJ6K8X+wg==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + } + } +} diff --git a/node/subscriptions-with-dodo-payments/package.json b/node/subscriptions-with-dodo-payments/package.json new file mode 100644 index 00000000..5f6269e5 --- /dev/null +++ b/node/subscriptions-with-dodo-payments/package.json @@ -0,0 +1,20 @@ +{ + "name": "subscriptions-with-dodo-payments", + "version": "1.0.0", + "description": "", + "main": "src/main.js", + "type": "module", + "scripts": { + "format": "prettier --write ." + }, + "dependencies": { + "dodopayments": "^2.54.0", + "node-appwrite": "^29.1.0" + }, + "devDependencies": { + "prettier": "^3.9.9" + }, + "engines": { + "node": ">=20.0.0" + } +} diff --git a/node/subscriptions-with-dodo-payments/src/appwrite.js b/node/subscriptions-with-dodo-payments/src/appwrite.js new file mode 100644 index 00000000..8351e136 --- /dev/null +++ b/node/subscriptions-with-dodo-payments/src/appwrite.js @@ -0,0 +1,45 @@ +import { Client, Users } from 'node-appwrite'; + +const LabelsSubscriber = 'subscriber'; + +class AppwriteService { + constructor(apiKey) { + const client = new Client(); + client + .setEndpoint(process.env.APPWRITE_FUNCTION_API_ENDPOINT) + .setProject(process.env.APPWRITE_FUNCTION_PROJECT_ID) + .setKey(apiKey); + + this.users = new Users(client); + } + + /** + * @param {string} userId + * @returns {Promise} + */ + async deleteSubscription(userId) { + const { labels } = await this.users.get({ userId }); + if (!labels.includes(LabelsSubscriber)) return; + + await this.users.updateLabels({ + userId, + labels: labels.filter((label) => label !== LabelsSubscriber), + }); + } + + /** + * @param {string} userId + * @returns {Promise} + */ + async createSubscription(userId) { + const { labels } = await this.users.get({ userId }); + if (labels.includes(LabelsSubscriber)) return; + + await this.users.updateLabels({ + userId, + labels: [...labels, LabelsSubscriber], + }); + } +} + +export default AppwriteService; diff --git a/node/subscriptions-with-dodo-payments/src/dodopayments.js b/node/subscriptions-with-dodo-payments/src/dodopayments.js new file mode 100644 index 00000000..68359f2d --- /dev/null +++ b/node/subscriptions-with-dodo-payments/src/dodopayments.js @@ -0,0 +1,66 @@ +import DodoPayments from 'dodopayments'; + +class DodoPaymentsService { + constructor() { + this.client = new DodoPayments({ + bearerToken: process.env.DODO_PAYMENTS_API_KEY, + webhookKey: process.env.DODO_PAYMENTS_WEBHOOK_KEY, + // The SDK defaults to live mode, so fall back to test mode explicitly. + environment: process.env.DODO_PAYMENTS_ENVIRONMENT || 'test_mode', + }); + } + + async createSubscription( + context, + userId, + userEmail, + userName, + successUrl, + failureUrl + ) { + try { + return await this.client.checkoutSessions.create({ + product_cart: [ + { product_id: process.env.DODO_PAYMENTS_PRODUCT_ID, quantity: 1 }, + ], + // Test mode sends real emails, so only prefill an address the user entered. + customer: userEmail ? { email: userEmail, name: userName } : undefined, + metadata: { + user_id: userId, + }, + return_url: successUrl, + cancel_url: failureUrl, + }); + } catch (err) { + context.error(err); + return null; + } + } + + /** + * Verifies the Standard Webhooks signature of a Dodo Payments webhook + * @param {*} context + * @returns {*} The verified event, or null if verification failed + */ + validateWebhook(context) { + try { + const { headers } = context.req; + // unwrap() only verifies the signature when headers are passed. + const event = this.client.webhooks.unwrap(context.req.bodyText, { + headers: { + 'webhook-id': headers['webhook-id'], + 'webhook-signature': headers['webhook-signature'], + 'webhook-timestamp': headers['webhook-timestamp'], + }, + }); + + context.log('Webhook signature is valid.'); + return event; + } catch (err) { + context.error(err); + return null; + } + } +} + +export default DodoPaymentsService; diff --git a/node/subscriptions-with-dodo-payments/src/main.js b/node/subscriptions-with-dodo-payments/src/main.js new file mode 100644 index 00000000..62081344 --- /dev/null +++ b/node/subscriptions-with-dodo-payments/src/main.js @@ -0,0 +1,106 @@ +import AppwriteService from './appwrite.js'; +import { getStaticFile, interpolate, throwIfMissing } from './utils.js'; +import DodoPaymentsService from './dodopayments.js'; + +// past_due is the grace period while Dodo Payments retries a failed renewal, +// so the customer keeps access. Every other status removes it. +const StatusesWithAccess = ['active', 'past_due']; + +export default async (context) => { + const { req, res, log, error } = context; + + throwIfMissing(process.env, [ + 'DODO_PAYMENTS_API_KEY', + 'DODO_PAYMENTS_WEBHOOK_KEY', + 'DODO_PAYMENTS_PRODUCT_ID', + ]); + + if (req.method === 'GET') { + const html = interpolate(getStaticFile('index.html'), { + APPWRITE_FUNCTION_API_ENDPOINT: + process.env.APPWRITE_FUNCTION_API_ENDPOINT, + APPWRITE_FUNCTION_PROJECT_ID: process.env.APPWRITE_FUNCTION_PROJECT_ID, + APPWRITE_FUNCTION_ID: process.env.APPWRITE_FUNCTION_ID, + }); + + return res.text(html, 200, { 'Content-Type': 'text/html; charset=utf-8' }); + } + + const appwrite = new AppwriteService(req.headers['x-appwrite-key']); + const dodopayments = new DodoPaymentsService(); + + switch (req.path) { + case '/subscribe': + const body = req.bodyJson; + const fallbackUrl = req.scheme + '://' + req.headers['host'] + '/'; + const successUrl = body.successUrl ?? fallbackUrl; + const failureUrl = body.failureUrl ?? fallbackUrl; + + const userId = req.headers['x-appwrite-user-id']; + + if (!userId) { + error('User ID not found in request.'); + return res.redirect(failureUrl, 303); + } + + const checkout = await dodopayments.createSubscription( + context, + userId, + body.email, + body.name, + successUrl, + failureUrl + ); + if (!checkout?.checkout_url) { + error('Failed to create Dodo Payments checkout.'); + return res.redirect(failureUrl, 303); + } + + log( + `Created Dodo Payments checkout ${checkout.session_id} for user ${userId}.` + ); + return res.redirect(checkout.checkout_url, 303); + + case '/webhook': + const event = dodopayments.validateWebhook(context); + if (!event) { + return res.json({ success: false }, 401); + } + + if (!event.type.startsWith('subscription.')) { + log(`Ignored Dodo Payments event ${event.type}.`); + return res.json({ success: true }); + } + + const subscription = event.data; + const subscriptionId = subscription.subscription_id; + const subscriptionUserId = subscription.metadata?.user_id; + + // Dodo Payments retries any non-2xx response, and a subscription without + // a user ID will never succeed, so acknowledge it instead. + if (!subscriptionUserId) { + error(`Subscription ${subscriptionId} has no user_id in its metadata.`); + return res.json({ success: true }); + } + + // Events can arrive late or out of order, but each one carries the + // subscription's current status. Deciding from the status rather than + // the event type keeps a delayed event from restoring lost access. + if (StatusesWithAccess.includes(subscription.status)) { + await appwrite.createSubscription(subscriptionUserId); + log( + `Subscription ${subscriptionId} is ${subscription.status}, user ${subscriptionUserId} has the subscriber label.` + ); + } else { + await appwrite.deleteSubscription(subscriptionUserId); + log( + `Subscription ${subscriptionId} is ${subscription.status}, user ${subscriptionUserId} doesn't have the subscriber label.` + ); + } + + return res.json({ success: true }); + + default: + return res.text('Not Found', 404); + } +}; diff --git a/node/subscriptions-with-dodo-payments/src/utils.js b/node/subscriptions-with-dodo-payments/src/utils.js new file mode 100644 index 00000000..1a8951bd --- /dev/null +++ b/node/subscriptions-with-dodo-payments/src/utils.js @@ -0,0 +1,43 @@ +import path from 'path'; +import { fileURLToPath } from 'url'; +import fs from 'fs'; + +/** + * Throws an error if any of the keys are missing from the object + * @param {*} obj + * @param {string[]} keys + * @throws {Error} + */ +export function throwIfMissing(obj, keys) { + const missing = []; + for (let key of keys) { + if (!(key in obj) || !obj[key]) { + missing.push(key); + } + } + if (missing.length > 0) { + throw new Error(`Missing required fields: ${missing.join(', ')}`); + } +} + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const staticFolder = path.join(__dirname, '../static'); + +/** + * Returns the contents of a file in the static folder + * @param {string} fileName + * @returns {string} Contents of static/{fileName} + */ +export function getStaticFile(fileName) { + return fs.readFileSync(path.join(staticFolder, fileName)).toString(); +} + +/** + * @param {string} template + * @param {Record} values + * @returns {string} + */ +export function interpolate(template, values) { + return template.replace(/{{([^}]+)}}/g, (_, key) => values[key] || ''); +} diff --git a/node/subscriptions-with-dodo-payments/static/index.html b/node/subscriptions-with-dodo-payments/static/index.html new file mode 100644 index 00000000..d8168fcd --- /dev/null +++ b/node/subscriptions-with-dodo-payments/static/index.html @@ -0,0 +1,199 @@ + + + + + + + Subscriptions with Dodo Payments + + + + + + + +
+
+
+
+

Subscriptions with Dodo Payments

+ +
+

+ Use this demo to create a Dodo Payments subscription. While the + subscription is active, the user has a subscriber + label. You can then use this to give permissions to subscribers + only. +

+
+
+
+
+ + + +
+
+
+ + + + + From 69f80e29cab7875dd831dc6897c29ff88167c593 Mon Sep 17 00:00:00 2001 From: adityaoberai Date: Fri, 9 Oct 2026 02:45:36 +0530 Subject: [PATCH 3/4] feat: restyle the Dodo Payments demo pages after appwrite.io Both demo pages now use the appwrite.io design tokens: the dark background, card and border colours, the pink call-to-action button and the gradient hero title. Light mode follows the visitor's system setting. Inter loads from jsDelivr on a caret range, and the Pink stylesheets are gone, with inline SVG for the three icons. Also adds a refresh button for the subscription status and disables the checkout buttons while a checkout is being created. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TaBGMrHMJFCb9gAU8rQb6A --- .../static/index.html | 542 ++++++++++++++---- .../static/index.html | 531 +++++++++++++---- 2 files changed, 850 insertions(+), 223 deletions(-) diff --git a/node/payments-with-dodo-payments/static/index.html b/node/payments-with-dodo-payments/static/index.html index 6ba1fd84..2b158154 100644 --- a/node/payments-with-dodo-payments/static/index.html +++ b/node/payments-with-dodo-payments/static/index.html @@ -2,148 +2,456 @@ - Payments with Dodo Payments - - - + + + - -
-
-
-
-

Payments with Dodo Payments

- -
-

- Use this demo to create a Dodo Payments payment. Once paid, a row is - created in an Appwrite table. You can then use this to give access - to video courses, SaaS payments, or any other digital goods and - services. -

-
-
-
-
- - +
From f6b89d4d4e4cf8176e38688d6d85f990273937a4 Mon Sep 17 00:00:00 2001 From: adityaoberai Date: Sat, 10 Oct 2026 02:32:38 +0530 Subject: [PATCH 4/4] feat: verify Dodo Payments purchases on both webhook and redirect The webhook now fetches the payment or subscription from Dodo Payments 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. Order row IDs and idempotent label updates make concurrent runs safe. Co-Authored-By: Claude Opus 5.5 --- node/payments-with-dodo-payments/README.md | 9 +- .../src/appwrite.js | 6 +- .../src/dodopayments.js | 16 +++- node/payments-with-dodo-payments/src/main.js | 96 +++++++++++++------ .../README.md | 9 +- .../src/dodopayments.js | 16 +++- .../src/main.js | 91 ++++++++++++------ 7 files changed, 178 insertions(+), 65 deletions(-) diff --git a/node/payments-with-dodo-payments/README.md b/node/payments-with-dodo-payments/README.md index afc4e8fb..ee793c32 100644 --- a/node/payments-with-dodo-payments/README.md +++ b/node/payments-with-dodo-payments/README.md @@ -19,6 +19,7 @@ This endpoint creates a [Dodo Payments checkout session](https://docs.dodopaymen | x-appwrite-user-id | User ID from Appwrite. | Header | String | 642...7cd | | Content-Type | The content type of the request body | Header | `application/json` | N/A | | successUrl | The URL to return to after checkout. Defaults to the function domain. | Body | String | https://example.com/success | +| verifyUrl | Where the provider returns the user to verify the purchase. Defaults to `/success` on the `successUrl` origin. | Body | String | https:///success | | failureUrl | The URL to redirect to after a cancelled or failed checkout. Defaults to the function domain. | Body | String | https://example.com/failure | | email | Optional. Prefills the customer's email address in the checkout. Test mode sends real emails too. | Body | String | jane@example.com | | name | Optional. Prefills the customer's name in the checkout when `email` is also set. | Body | String | Jane Doe | @@ -37,9 +38,15 @@ Location: https://test.checkout.dodopayments.com/session/cks_Gi6KGJ2zFJo9rq9Ukif Location: https://example.com/failure ``` +### `GET /success` + +Dodo Payments redirects the user here after checkout. Dodo Payments adds `payment_id` to the redirect URL, and this endpoint fetches the payment from Dodo Payments with it. It provisions the user the same way the webhook does, then redirects to the `successUrl` given to the checkout endpoint. + +The webhook and this endpoint both provision the user, so either one works if the other fails. Running twice is safe: orders use the provider ID as the row ID, so the database rejects a second write. + ### `POST /webhook` -This endpoint receives Dodo Payments webhooks and handles the `payment.succeeded` event. It verifies the [Standard Webhooks](https://www.standardwebhooks.com/) signature in the `webhook-id`, `webhook-signature` and `webhook-timestamp` headers. If the verification fails, a `401` response is sent. +This endpoint receives Dodo Payments webhooks and handles the `payment.succeeded` event. It verifies the [Standard Webhooks](https://www.standardwebhooks.com/) signature in the `webhook-id`, `webhook-signature` and `webhook-timestamp` headers. If the verification fails, a `401` response is sent. The webhook payload isn't trusted: the function fetches the payment from Dodo Payments and stores the order only if its status is `succeeded`. On `payment.succeeded`, the function creates the orders database and table if they don't exist yet, then creates a row for the order. The row ID is the Dodo Payments payment ID, so a repeated delivery of the same event doesn't store the order twice. Only the user who paid can read the row. diff --git a/node/payments-with-dodo-payments/src/appwrite.js b/node/payments-with-dodo-payments/src/appwrite.js index 9fab0099..a268b571 100644 --- a/node/payments-with-dodo-payments/src/appwrite.js +++ b/node/payments-with-dodo-payments/src/appwrite.js @@ -61,8 +61,10 @@ class AppwriteService { } /** - * Stores a paid order, using the order ID as the row ID so a repeated - * webhook delivery can't store the same order twice + * Stores a paid order. The webhook and the /success redirect can both run + * for the same payment, possibly at the same time. Using the order ID as the + * row ID makes the database reject the second write with a 409, so the + * order is only ever stored once. * @param {string} databaseId * @param {string} tableId * @param {string} userId diff --git a/node/payments-with-dodo-payments/src/dodopayments.js b/node/payments-with-dodo-payments/src/dodopayments.js index eeddddad..2c3e07bc 100644 --- a/node/payments-with-dodo-payments/src/dodopayments.js +++ b/node/payments-with-dodo-payments/src/dodopayments.js @@ -15,6 +15,7 @@ class DodoPaymentsService { userId, userEmail, userName, + verifyUrl, successUrl, failureUrl ) { @@ -27,8 +28,12 @@ class DodoPaymentsService { customer: userEmail ? { email: userEmail, name: userName } : undefined, metadata: { user_id: userId, + success_url: successUrl, }, - return_url: successUrl, + // Dodo Payments returns the user to this function first, with the + // payment ID in the query string. It verifies the payment and then + // redirects to success_url. + return_url: verifyUrl, cancel_url: failureUrl, }); } catch (err) { @@ -37,6 +42,15 @@ class DodoPaymentsService { } } + /** + * Fetches a payment from Dodo Payments, so neither the webhook payload nor + * the redirect query string has to be trusted + * @param {string} paymentId + */ + async getPayment(paymentId) { + return await this.client.payments.retrieve(paymentId); + } + /** * Verifies the Standard Webhooks signature of a Dodo Payments webhook * @param {*} context diff --git a/node/payments-with-dodo-payments/src/main.js b/node/payments-with-dodo-payments/src/main.js index 029291c5..57f54ca4 100644 --- a/node/payments-with-dodo-payments/src/main.js +++ b/node/payments-with-dodo-payments/src/main.js @@ -14,6 +14,64 @@ export default async (context) => { const databaseId = process.env.APPWRITE_DATABASE_ID || 'orders'; const tableId = process.env.APPWRITE_TABLE_ID || 'orders'; + const appwrite = new AppwriteService(req.headers['x-appwrite-key']); + const dodopayments = new DodoPaymentsService(); + + /** + * Stores the order if Dodo Payments reports the payment as succeeded. Both + * the webhook and the /success redirect call this, so the order is stored + * even if one of them never arrives. + * @param {string} paymentId + */ + async function fulfillPayment(paymentId) { + const payment = await dodopayments.getPayment(paymentId); + + if (payment.status !== 'succeeded') { + log(`Payment ${payment.payment_id} is ${payment.status}, not fulfilling.`); + return payment; + } + + const orderUserId = payment.metadata?.user_id; + const orderId = payment.payment_id; + + if (!orderUserId) { + error(`Payment ${orderId} has no user_id in its metadata.`); + return payment; + } + + await appwrite.setup(databaseId, tableId); + + const created = await appwrite.createOrder( + databaseId, + tableId, + orderUserId, + orderId + ); + log( + created + ? `Created order row for user ${orderUserId} with Dodo Payments payment ID ${orderId}.` + : `Order ${orderId} was already stored.` + ); + return payment; + } + + if (req.method === 'GET' && req.path === '/success') { + let successUrl = '/'; + + try { + // A cancelled or failed checkout can come back without a payment ID. + if (req.query.payment_id) { + const payment = await fulfillPayment(req.query.payment_id); + successUrl = payment.metadata?.success_url || successUrl; + } + } catch (err) { + // The webhook still fulfills the order, so send the user on regardless. + error(err); + } + + return res.redirect(successUrl, 303); + } + if (req.method === 'GET') { const html = interpolate(getStaticFile('index.html'), { APPWRITE_FUNCTION_API_ENDPOINT: @@ -27,9 +85,6 @@ export default async (context) => { return res.text(html, 200, { 'Content-Type': 'text/html; charset=utf-8' }); } - const appwrite = new AppwriteService(req.headers['x-appwrite-key']); - const dodopayments = new DodoPaymentsService(); - switch (req.path) { case '/checkout': const body = req.bodyJson; @@ -37,6 +92,11 @@ export default async (context) => { const successUrl = body.successUrl ?? fallbackUrl; const failureUrl = body.failureUrl ?? fallbackUrl; + // Dodo Payments returns the user to this function first, which verifies + // the payment and then redirects to successUrl. + const verifyUrl = + body.verifyUrl ?? new URL('/success', successUrl).toString(); + const userId = req.headers['x-appwrite-user-id']; if (!userId) { @@ -49,6 +109,7 @@ export default async (context) => { userId, body.email, body.name, + verifyUrl, successUrl, failureUrl ); @@ -73,33 +134,8 @@ export default async (context) => { return res.json({ success: true }); } - const payment = event.data; - const orderUserId = payment.metadata?.user_id; - const orderId = payment.payment_id; - - // Dodo Payments retries any non-2xx response, and a payment without a - // user ID will never succeed, so acknowledge it instead. - if (!orderUserId) { - error(`Payment ${orderId} has no user_id in its metadata.`); - return res.json({ success: true }); - } - - await appwrite.setup(databaseId, tableId); - - const created = await appwrite.createOrder( - databaseId, - tableId, - orderUserId, - orderId - ); - if (!created) { - log(`Order ${orderId} was already stored.`); - return res.json({ success: true }); - } - - log( - `Created order row for user ${orderUserId} with Dodo Payments payment ID ${orderId}.` - ); + // Look the payment up instead of trusting the event payload. + await fulfillPayment(event.data.payment_id); return res.json({ success: true }); default: diff --git a/node/subscriptions-with-dodo-payments/README.md b/node/subscriptions-with-dodo-payments/README.md index e5225bea..00d968e8 100644 --- a/node/subscriptions-with-dodo-payments/README.md +++ b/node/subscriptions-with-dodo-payments/README.md @@ -19,6 +19,7 @@ This endpoint creates a [Dodo Payments checkout session](https://docs.dodopaymen | x-appwrite-user-id | User ID from Appwrite. | Header | String | 642...7cd | | Content-Type | The content type of the request body | Header | `application/json` | N/A | | successUrl | The URL to return to after checkout. Defaults to the function domain. | Body | String | https://example.com/success | +| verifyUrl | Where the provider returns the user to verify the purchase. Defaults to `/success` on the `successUrl` origin. | Body | String | https:///success | | failureUrl | The URL to redirect to after a cancelled or failed checkout. Defaults to the function domain. | Body | String | https://example.com/failure | | email | Optional. Prefills the customer's email address in the checkout. Test mode sends real emails too. | Body | String | jane@example.com | | name | Optional. Prefills the customer's name in the checkout when `email` is also set. | Body | String | Jane Doe | @@ -37,9 +38,15 @@ Location: https://test.checkout.dodopayments.com/session/cks_Gi6KGJ2zFJo9rq9Ukif Location: https://example.com/failure ``` +### `GET /success` + +Dodo Payments redirects the user here after checkout. Dodo Payments adds `subscription_id` to the redirect URL, and this endpoint fetches the subscription from Dodo Payments with it. It provisions the user the same way the webhook does, then redirects to the `successUrl` given to the checkout endpoint. + +The webhook and this endpoint both provision the user, so either one works if the other fails. Running twice is safe: each run applies the latest status from the provider, and adding or removing a label that is already set or unset does nothing. + ### `POST /webhook` -This endpoint receives Dodo Payments webhooks and handles every `subscription.*` event. It verifies the [Standard Webhooks](https://www.standardwebhooks.com/) signature in the `webhook-id`, `webhook-signature` and `webhook-timestamp` headers. If the verification fails, a `401` response is sent. +This endpoint receives Dodo Payments webhooks and handles every `subscription.*` event. It verifies the [Standard Webhooks](https://www.standardwebhooks.com/) signature in the `webhook-id`, `webhook-signature` and `webhook-timestamp` headers. If the verification fails, a `401` response is sent. The webhook payload isn't trusted: the function fetches the subscription from Dodo Payments and adds or removes the `subscriber` label based on its current status. Webhooks can arrive late, twice or out of order, but each one carries the subscription's current status. The function decides from that status rather than the event type, so a delayed event can't give access back after a cancellation: diff --git a/node/subscriptions-with-dodo-payments/src/dodopayments.js b/node/subscriptions-with-dodo-payments/src/dodopayments.js index 68359f2d..7fb7de45 100644 --- a/node/subscriptions-with-dodo-payments/src/dodopayments.js +++ b/node/subscriptions-with-dodo-payments/src/dodopayments.js @@ -15,6 +15,7 @@ class DodoPaymentsService { userId, userEmail, userName, + verifyUrl, successUrl, failureUrl ) { @@ -27,8 +28,12 @@ class DodoPaymentsService { customer: userEmail ? { email: userEmail, name: userName } : undefined, metadata: { user_id: userId, + success_url: successUrl, }, - return_url: successUrl, + // Dodo Payments returns the user to this function first, with the + // subscription ID in the query string. It verifies the subscription and then + // redirects to success_url. + return_url: verifyUrl, cancel_url: failureUrl, }); } catch (err) { @@ -37,6 +42,15 @@ class DodoPaymentsService { } } + /** + * Fetches a subscription from Dodo Payments, so neither the webhook payload + * nor the redirect query string has to be trusted + * @param {string} subscriptionId + */ + async getSubscription(subscriptionId) { + return await this.client.subscriptions.retrieve(subscriptionId); + } + /** * Verifies the Standard Webhooks signature of a Dodo Payments webhook * @param {*} context diff --git a/node/subscriptions-with-dodo-payments/src/main.js b/node/subscriptions-with-dodo-payments/src/main.js index 62081344..b3ad4ea4 100644 --- a/node/subscriptions-with-dodo-payments/src/main.js +++ b/node/subscriptions-with-dodo-payments/src/main.js @@ -15,6 +15,59 @@ export default async (context) => { 'DODO_PAYMENTS_PRODUCT_ID', ]); + const appwrite = new AppwriteService(req.headers['x-appwrite-key']); + const dodopayments = new DodoPaymentsService(); + + /** + * Gives or removes the subscriber label based on the subscription's current + * status in Dodo Payments. Both the webhook and the /success redirect call + * this. They can run at the same time or out of order, but each one applies + * the latest status from Dodo Payments and the label updates are + * idempotent, so they always settle on the same result. + * @param {string} subscriptionId + */ + async function syncSubscription(subscriptionId) { + const subscription = await dodopayments.getSubscription(subscriptionId); + const subscriptionUserId = subscription.metadata?.user_id; + + if (!subscriptionUserId) { + error(`Subscription ${subscriptionId} has no user_id in its metadata.`); + return subscription; + } + + if (StatusesWithAccess.includes(subscription.status)) { + await appwrite.createSubscription(subscriptionUserId); + log( + `Subscription ${subscriptionId} is ${subscription.status}, user ${subscriptionUserId} has the subscriber label.` + ); + } else { + await appwrite.deleteSubscription(subscriptionUserId); + log( + `Subscription ${subscriptionId} is ${subscription.status}, user ${subscriptionUserId} doesn't have the subscriber label.` + ); + } + return subscription; + } + + if (req.method === 'GET' && req.path === '/success') { + let successUrl = '/'; + + try { + // A cancelled or failed checkout can come back without a subscription ID. + if (req.query.subscription_id) { + const subscription = await syncSubscription( + req.query.subscription_id + ); + successUrl = subscription.metadata?.success_url || successUrl; + } + } catch (err) { + // The webhook still provisions the user, so send them on regardless. + error(err); + } + + return res.redirect(successUrl, 303); + } + if (req.method === 'GET') { const html = interpolate(getStaticFile('index.html'), { APPWRITE_FUNCTION_API_ENDPOINT: @@ -26,9 +79,6 @@ export default async (context) => { return res.text(html, 200, { 'Content-Type': 'text/html; charset=utf-8' }); } - const appwrite = new AppwriteService(req.headers['x-appwrite-key']); - const dodopayments = new DodoPaymentsService(); - switch (req.path) { case '/subscribe': const body = req.bodyJson; @@ -36,6 +86,11 @@ export default async (context) => { const successUrl = body.successUrl ?? fallbackUrl; const failureUrl = body.failureUrl ?? fallbackUrl; + // Dodo Payments returns the user to this function first, which verifies + // the subscription and then redirects to successUrl. + const verifyUrl = + body.verifyUrl ?? new URL('/success', successUrl).toString(); + const userId = req.headers['x-appwrite-user-id']; if (!userId) { @@ -48,6 +103,7 @@ export default async (context) => { userId, body.email, body.name, + verifyUrl, successUrl, failureUrl ); @@ -72,32 +128,9 @@ export default async (context) => { return res.json({ success: true }); } - const subscription = event.data; - const subscriptionId = subscription.subscription_id; - const subscriptionUserId = subscription.metadata?.user_id; - - // Dodo Payments retries any non-2xx response, and a subscription without - // a user ID will never succeed, so acknowledge it instead. - if (!subscriptionUserId) { - error(`Subscription ${subscriptionId} has no user_id in its metadata.`); - return res.json({ success: true }); - } - - // Events can arrive late or out of order, but each one carries the - // subscription's current status. Deciding from the status rather than - // the event type keeps a delayed event from restoring lost access. - if (StatusesWithAccess.includes(subscription.status)) { - await appwrite.createSubscription(subscriptionUserId); - log( - `Subscription ${subscriptionId} is ${subscription.status}, user ${subscriptionUserId} has the subscriber label.` - ); - } else { - await appwrite.deleteSubscription(subscriptionUserId); - log( - `Subscription ${subscriptionId} is ${subscription.status}, user ${subscriptionUserId} doesn't have the subscriber label.` - ); - } - + // Events can arrive late or out of order, so look the subscription up + // and apply its current status instead of trusting the event payload. + await syncSubscription(event.data.subscription_id); return res.json({ success: true }); default: