├── .env.example ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .vscode ├── extensions.json └── snippets.code-snippets ├── LICENSE ├── README.md ├── _developer ├── tomlWriter.js ├── types │ ├── 2025-04 │ │ └── webhooks.js │ ├── toml.js │ └── webhookTopics.js └── webhookWriter.js ├── bun.lock ├── client ├── App.jsx ├── Routes.jsx ├── components │ └── .gitkeep ├── entry-client.jsx ├── hooks │ └── .gitkeep ├── index.html ├── pages │ ├── Index.jsx │ └── debug │ │ ├── Billing.jsx │ │ ├── Data.jsx │ │ ├── Index.jsx │ │ └── Scopes.jsx ├── providers │ └── AppBridgeProvider.jsx ├── public │ └── favicon.ico └── vite.config.js ├── docs ├── NOTES.md ├── SETUP.md ├── SNIPPETS.md └── migrations │ ├── graphql-webhooks-to-managed-webhooks.md │ └── oauth-to-managed-installation.md ├── package.json ├── server ├── controllers │ └── gdpr.js ├── index.js ├── middleware │ ├── csp.js │ ├── isInitialLoad.js │ ├── verifyCheckout.js │ ├── verifyHmac.js │ ├── verifyProxy.js │ └── verifyRequest.js ├── routes │ ├── app_proxy │ │ └── index.js │ ├── checkout │ │ └── index.js │ └── index.js └── webhooks │ ├── _index.js │ └── app_uninstalled.js └── utils ├── clientProvider.js ├── freshInstall.js ├── models ├── SessionModel.js └── StoreModel.js ├── sessionHandler.js ├── setupCheck.js ├── shopify.js └── validateJWT.js /.env.example: -------------------------------------------------------------------------------- 1 | SHOPIFY_API_KEY= 2 | SHOPIFY_API_SECRET= 3 | SHOPIFY_API_SCOPES= 4 | SHOPIFY_API_OPTIONAL_SCOPES= 5 | SHOPIFY_APP_URL=https://ngrok-url.io 6 | SHOPIFY_API_VERSION="2025-01" 7 | MONGO_URL= 8 | ENCRYPTION_STRING= 9 | NPM_CONFIG_FORCE=true 10 | 11 | ## The value for this must be exactly the same as `SHOPIFY_API_KEY` above 12 | VITE_SHOPIFY_API_KEY= 13 | 14 | ## App Details 15 | # App's name as in your Partner dashboard. Ex: "My App" 16 | APP_NAME= 17 | # App's URL that you want in the store. No spaces, use `-` instead. Ex: "my-app" 18 | APP_HANDLE= 19 | 20 | ## App Proxy 21 | APP_PROXY_PREFIX="apps" 22 | APP_PROXY_SUBPATH="" 23 | # Prefix can be `apps`, `a`, `community` or `tools`. Any other value will yield in errors. 24 | # Proxy URL is autofilled 25 | # If `APP_PROXY_PREFIX` or `APP_PROXY_SUBPATH` is left blank, no app proxy entry is created 26 | 27 | ## Point of Sale 28 | POS_EMBEDDED=false 29 | 30 | ## Access 31 | DIRECT_API_MODE= 32 | # Direct API Mode can be either `online` or `offline` 33 | EMBEDDED_APP_DIRECT_API_ACCESS= 34 | # Embedded app direct api access mode is either true or false. 35 | # Read more about direct api access here: 36 | # https://shopify.dev/docs/api/admin-extensions#direct-api-access 37 | # No entries are created if left blank 38 | 39 | # To quickly install the app on your store, use this URL: 40 | # https://storename.myshopify.com/admin/oauth/install?client_id=SHOPIFY_API_KEY 41 | 42 | ## Notes: 43 | # Ensure SHOPIFY_APP_URL starts with `https://`. 44 | # When deploying to a service like Heroku, ensure NPM_CONFIG_FORCE is set to `true` so it runs `npm i --force` instead of just `npm i`. 45 | # Updating anything in your `env` requires a restart of the dev server. 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Personal Dev Content 2 | mongo/ 3 | shopify.*.toml 4 | 5 | # macOS 6 | .DS_Store 7 | 8 | # Package-Lock 9 | package-lock.json 10 | bun.lockb 11 | 12 | # Logs 13 | logs 14 | *.log 15 | npm-debug.log* 16 | yarn-debug.log* 17 | yarn-error.log* 18 | lerna-debug.log* 19 | 20 | # Diagnostic reports (https://nodejs.org/api/report.html) 21 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 22 | 23 | # Runtime data 24 | pids 25 | *.pid 26 | *.seed 27 | *.pid.lock 28 | 29 | # Directory for instrumented libs generated by jscoverage/JSCover 30 | lib-cov 31 | 32 | # Coverage directory used by tools like istanbul 33 | coverage 34 | *.lcov 35 | 36 | # nyc test coverage 37 | .nyc_output 38 | 39 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 40 | .grunt 41 | 42 | # Bower dependency directory (https://bower.io/) 43 | bower_components 44 | 45 | # node-waf configuration 46 | .lock-wscript 47 | 48 | # Compiled binary addons (https://nodejs.org/api/addons.html) 49 | build/Release 50 | 51 | # Dependency directories 52 | node_modules/ 53 | jspm_packages/ 54 | 55 | # TypeScript v1 declaration files 56 | typings/ 57 | 58 | # TypeScript cache 59 | *.tsbuildinfo 60 | 61 | # Optional npm cache directory 62 | .npm 63 | 64 | # Optional eslint cache 65 | .eslintcache 66 | 67 | # Microbundle cache 68 | .rpt2_cache/ 69 | .rts2_cache_cjs/ 70 | .rts2_cache_es/ 71 | .rts2_cache_umd/ 72 | 73 | # Optional REPL history 74 | .node_repl_history 75 | 76 | # Output of 'npm pack' 77 | *.tgz 78 | 79 | # Yarn Integrity file 80 | .yarn-integrity 81 | 82 | # dotenv environment variables file 83 | .env 84 | .env.test 85 | 86 | # parcel-bundler cache (https://parceljs.org/) 87 | .cache 88 | 89 | # Next.js build output 90 | .next 91 | 92 | # Nuxt.js build / generate output 93 | .nuxt 94 | dist 95 | 96 | # Gatsby files 97 | .cache/ 98 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 99 | # https://nextjs.org/blog/next-9-1#public-directory-support 100 | # public 101 | 102 | # vuepress build output 103 | .vuepress/dist 104 | 105 | # Serverless directories 106 | .serverless/ 107 | 108 | # FuseBox cache 109 | .fusebox/ 110 | 111 | # DynamoDB Local files 112 | .dynamodb/ 113 | 114 | # TernJS port file 115 | .tern-port 116 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | ## Prettier will ignore following files and directories 2 | dist/ 3 | mongo/ 4 | node_modules/ 5 | package-lock.json -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "singleQuote": false, 4 | "trailingComma": "es5", 5 | "semi": true, 6 | "bracketSpacing": true, 7 | "arrowParens": "always" 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["pomdtr.excalidraw-editor"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/snippets.code-snippets: -------------------------------------------------------------------------------- 1 | { 2 | "Arrow Function Component": { 3 | "prefix": "sfc", 4 | "body": [ 5 | "const $1 = ($2) => {", 6 | " return ( $0 );", 7 | "}", 8 | " ", 9 | "export default $1;", 10 | ], 11 | "description": "Arrow Function Component", 12 | }, 13 | 14 | "Polaris Page": { 15 | "prefix": "createNewPage", 16 | "body": [ 17 | "import { BlockStack, InlineStack, Button, Card, Layout, Page, Text } from \"@shopify/polaris\";", 18 | "import { navigate } from \"raviger\";", 19 | "", 20 | "const $1 = () => {", 21 | " return (", 22 | " <>", 23 | " {", 27 | " navigate('/');", 28 | " },", 29 | " }}", 30 | " >", 31 | " ", 32 | " ", 33 | " ", 34 | " ", 35 | " Heading", 36 | " Regular Text Content", 37 | " ", 38 | " {", 41 | " alert('Button pressed');", 42 | " }}", 43 | " >", 44 | " Button", 45 | " ", 46 | " ", 47 | " ", 48 | " ", 49 | " ", 50 | " ", 51 | " ", 52 | " ", 53 | " );", 54 | "};", 55 | "", 56 | "export default $1;", 57 | ], 58 | "description": "Create a new page with navigation and layout components from Polaris.", 59 | }, 60 | 61 | "Create new /route || /app_proxy route": { 62 | "prefix": "createroute", 63 | "body": [ 64 | "//Import $1 to `./server/routes/index.js` for it to work", 65 | "//Press `tab` to cycle through and fill up information", 66 | "", 67 | "import { Router } from \"express\";", 68 | "const $1 = Router();", 69 | "", 70 | "/**", 71 | "* @param {import('express').Request} req - Express request object", 72 | "* @param {import('express').Response} res - Express response object", 73 | "*/", 74 | "$1.get(\"/$2\", async (req, res) => { //get / post / put / delete", 75 | " try {", 76 | " $3", 77 | " return res.status(200).send({ message: \"It works!\" });", 78 | " } catch (e) {", 79 | " console.error(`An error occured at /$2`); ", 80 | " return res.status(400).send({ error: true });", 81 | " }", 82 | "});", 83 | "", 84 | "export default $1;", 85 | ], 86 | "description": "Create new /route || /app_proxy route", 87 | }, 88 | 89 | "Webhook function": { 90 | "prefix": "createwebhook", 91 | "body": [ 92 | "//Press `tab` to cycle through and fill up information", 93 | "", 94 | "/**", 95 | "* Replace TOPIC_NAME with a Webhook Topic to enable autocomplete", 96 | "* @typedef { import(\"../../_developer/types/2025-04/webhooks.js\").${2:TOPIC_NAME} } webhookTopic", 97 | "*/", 98 | "", 99 | "const $1 = async (topic, shop, webhookRequestBody, webhookId, apiVersion) => {", 100 | " try {", 101 | " /** @type {webhookTopic} */", 102 | " const webhookBody = JSON.parse(webhookRequestBody);", 103 | " $3", 104 | " } catch (e) {", 105 | " console.error(e);", 106 | " }", 107 | "};", 108 | "", 109 | "export default $1;", 110 | ], 111 | "description": "Webhook function", 112 | }, 113 | 114 | "GraphQL Client Provider": { 115 | "prefix": "createOnlineClientGql", 116 | "body": [ 117 | "//Press `tab` to cycle through and fill up information", 118 | "//Import `clientProvider`", 119 | "", 120 | "const $1 = async (req, res) => {", 121 | " try {", 122 | " const { client } = await clientProvider.online.graphqlClient({", 123 | " shop: res.locals.user_session.shop", 124 | " });", 125 | "", 126 | " const response = await client.request(/* GraphQL */`{", 127 | " }`, //Paste your GraphQL query/mutation here", 128 | " );", 129 | "", 130 | " res.status(200).send(response);", 131 | " } catch (e) {", 132 | " console.error(e);", 133 | " return res.status(400).send({ error: true });", 134 | " }", 135 | "};", 136 | "", 137 | "export default $1;", 138 | ], 139 | "description": "GraphQL Client Provider", 140 | }, 141 | "Offline GraphQL Client Provider": { 142 | "prefix": "createOfflineClientGql", 143 | "body": [ 144 | "//Press `tab` to cycle through and fill up information", 145 | "//Import `clientProvider`", 146 | "", 147 | "const $1 = async (req, res) => {", 148 | " try {", 149 | " const { client } = await clientProvider.offline.graphqlClient({", 150 | " shop: res.locals.user_session.shop", 151 | " });", 152 | "", 153 | " const response = await client.request(/* GraphQL */`{", 154 | " }`, //Paste your GraphQL query/mutation here", 155 | " );", 156 | "", 157 | " return res.status(200).send(response);", 158 | " } catch (e) {", 159 | " console.error(e);", 160 | " return res.status(400).send({ error: true });", 161 | " }", 162 | "};", 163 | "", 164 | "export default $1;", 165 | ], 166 | "description": "Offline GraphQL Client Provider", 167 | }, 168 | "POST request": { 169 | "prefix": "createpost", 170 | "body": [ 171 | "const $1 = await(", 172 | "await fetch(\"/api/apps/$2\", {", 173 | "headers: {", 174 | " Accept: \"application/json\",", 175 | " \"Content-Type\": \"application/json\",", 176 | " },", 177 | " method: \"POST\",", 178 | " body: JSON.stringify(${3:body}),", 179 | "})", 180 | ").json();", 181 | ], 182 | "description": "Creates a new POST fetch request", 183 | }, 184 | 185 | "GET request": { 186 | "prefix": "createget", 187 | "body": [ 188 | "const $1 = await(", 189 | "await fetch(\"/api/apps/$2\", {", 190 | " method: \"GET\",", 191 | "})", 192 | ").json();", 193 | ], 194 | "description": "Creates a new GET fetch request", 195 | }, 196 | } 197 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Harshdeep Singh Hura 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Shopify Node.js x Express.js x React.js Boilerplate 2 | 3 | An embedded app starter template to get up and ready with Shopify app development with JavaScript. This is heavily influenced by the choices Shopify Engineering team made in building their [starter template](https://github.com/Shopify/shopify-app-template-node) to ensure smooth transition between templates. 4 | 5 | I've included [notes](/docs/NOTES.md) on this repo which goes over the repo on why certain choices were made. 6 | 7 | I also did make a video going over the entire repo. If you want to learn Shopify app dev in-depth, I also sell a course _[How To Build Shopify Apps](https://kinngh.gumroad.com/l/how-to-make-shopify-apps?utm_source=github&utm_medium=express-repo)_ 8 | 9 | [![How To Build Shopify Apps Course](https://raw.githubusercontent.com/kinngh/extras/main/csa_promo.png)](https://kinngh.gumroad.com/l/how-to-make-shopify-apps?utm_source=github&utm_medium=express-repo) 10 | 11 | [![Creating a Shopify app from scratch](https://img.youtube.com/vi/iV_3ENCraaM/0.jpg)](https://www.youtube.com/watch?v=iV_3ENCraaM) 12 | 13 | ## Supporting repositories 14 | 15 | - [`@kinngh/shopify-nextjs-prisma-app`](https://github.com/kinngh/shopify-nextjs-prisma-app): A Shopify app boilerplate built with Next.js and Prisma ORM, with deployments available on Vercel. 16 | - [`@kinngh/shopify-polaris-playground`](https://github.com/kinngh/shopify-polaris-playground): Build your app's UI using Polaris, without an internet connection. 17 | 18 | ## Tech Stack 19 | 20 | - React.js 21 | - `raviger` for routing. 22 | - Express.js 23 | - MongoDB 24 | - Vite 25 | - Ngrok 26 | 27 | ## Why I made this 28 | 29 | The Shopify CLI generates an amazing starter app but it still needs some more boilerplate code and customizations so I can jump on to building apps with a simple clone. This includes: 30 | 31 | - MongoDB based session and database management. 32 | - Monetization (recurring subscriptions) ready to go. 33 | - Webhooks isolated and setup. 34 | - React routing taken care of (I miss Next.js mostly because of routing and under the hood improvements). 35 | - Misc boilerplate code and templates to quickly setup inApp subscriptions, routes, webhooks and more. 36 | 37 | ## Notes 38 | 39 | ### Setup 40 | 41 | - Refer to [SETUP](/docs/SETUP.md) 42 | - Migrations are available in [DOCS](/docs/migrations/) 43 | 44 | ### Misc 45 | 46 | - Storing data is kept to a minimal to allow building custom models for flexibility. 47 | - Session persistence is also kept to a minimal and based on the Redis example provided by Shopify, but feel free to modify as required. 48 | -------------------------------------------------------------------------------- /_developer/tomlWriter.js: -------------------------------------------------------------------------------- 1 | import toml from "@iarna/toml"; 2 | import "dotenv/config"; 3 | import fs from "fs"; 4 | import path from "path"; 5 | import setupCheck from "../utils/setupCheck.js"; 6 | import webhookWriter from "./webhookWriter.js"; 7 | 8 | /** @typedef {import("@/_developer/types/toml.js").AppConfig} Config */ 9 | 10 | /** @type {Config} */ 11 | let config = {}; 12 | 13 | try { 14 | setupCheck(); //Run setup check to ensure all env variables are accessible 15 | 16 | let appUrl = process.env.SHOPIFY_APP_URL; 17 | if (appUrl.endsWith("/")) { 18 | appUrl = appUrl.slice(0, -1); 19 | } 20 | // Globals 21 | config.name = process.env.APP_NAME; 22 | config.handle = process.env.APP_HANDLE; 23 | config.client_id = process.env.SHOPIFY_API_KEY; 24 | config.application_url = appUrl; 25 | config.embedded = true; 26 | config.extension_directories = ["../extension/extensions/**"]; 27 | 28 | // Auth 29 | config.auth = {}; 30 | config.auth.redirect_urls = [`${appUrl}/api/`]; 31 | // Scopes 32 | config.access_scopes = {}; 33 | config.access_scopes.scopes = process.env.SHOPIFY_API_SCOPES; 34 | if (process.env.SHOPIFY_API_OPTIONAL_SCOPES?.trim()) { 35 | config.access_scopes.optional_scopes = 36 | process.env.SHOPIFY_API_OPTIONAL_SCOPES.split(",") 37 | .map((scope) => scope.trim()) 38 | .filter(Boolean); 39 | } 40 | config.access_scopes.use_legacy_install_flow = false; 41 | 42 | // Access 43 | if ( 44 | process.env.DIRECT_API_MODE && 45 | process.env.EMBEDDED_APP_DIRECT_API_ACCESS 46 | ) { 47 | config.access = {}; 48 | config.access.admin = {}; 49 | process.env.DIRECT_API_MODE 50 | ? (config.access.admin.direct_api_mode = process.env.DIRECT_API_MODE) 51 | : null; 52 | process.env.EMBEDDED_APP_DIRECT_API_ACCESS 53 | ? (config.access.admin.embedded_app_direct_api_access = 54 | process.env.EMBEDDED_APP_DIRECT_API_ACCESS === "true") 55 | : null; 56 | } 57 | 58 | // Webhook event version to always match the app API version 59 | config.webhooks = {}; 60 | config.webhooks.api_version = process.env.SHOPIFY_API_VERSION; 61 | 62 | // Webhooks 63 | webhookWriter(config); 64 | 65 | // GDPR URLs 66 | config.webhooks.privacy_compliance = {}; 67 | config.webhooks.privacy_compliance.customer_data_request_url = `${appUrl}/api/gdpr/customers_data_request`; 68 | config.webhooks.privacy_compliance.customer_deletion_url = `${appUrl}/api/gdpr/customers_redact`; 69 | config.webhooks.privacy_compliance.shop_deletion_url = `${appUrl}/api/gdpr/shop_redact`; 70 | 71 | // App Proxy 72 | if ( 73 | process.env.APP_PROXY_PREFIX?.length > 0 && 74 | process.env.APP_PROXY_SUBPATH?.length > 0 75 | ) { 76 | config.app_proxy = {}; 77 | config.app_proxy.url = `${appUrl}/api/proxy_route`; 78 | config.app_proxy.prefix = process.env.APP_PROXY_PREFIX; 79 | config.app_proxy.subpath = process.env.APP_PROXY_SUBPATH; 80 | } 81 | 82 | // PoS 83 | if (process.env.POS_EMBEDDED?.length > 1) { 84 | config.pos = {}; 85 | config.pos.embedded = process.env.POS_EMBEDDED === "true"; 86 | } 87 | 88 | //Build 89 | config.build = {}; 90 | config.build.include_config_on_deploy = true; 91 | 92 | //Write to toml 93 | let str = toml.stringify(config); 94 | str = 95 | "# Avoid writing to toml directly. Use your .env file instead\n\n" + str; 96 | 97 | fs.writeFileSync(path.join(process.cwd(), "shopify.app.toml"), str, (err) => { 98 | if (err) { 99 | console.log("An error occured while writing to file", e); 100 | return; 101 | } 102 | 103 | console.log("Written TOML to root"); 104 | return; 105 | }); 106 | 107 | const extensionsDir = path.join("..", "extension"); 108 | 109 | config.extension_directories = ["./extensions/**"]; 110 | let extensionStr = toml.stringify(config); 111 | extensionStr = 112 | "# Avoid writing to toml directly. Use your .env file instead\n\n" + 113 | extensionStr; 114 | 115 | config.extension_directories = ["extension/extensions/**"]; 116 | let globalStr = toml.stringify(config); 117 | globalStr = 118 | "# Avoid writing to toml directly. Use your .env file instead\n\n" + 119 | globalStr; 120 | 121 | if (fs.existsSync(extensionsDir)) { 122 | fs.writeFileSync( 123 | path.join(process.cwd(), "..", "shopify.app.toml"), 124 | globalStr, 125 | (err) => { 126 | if (err) { 127 | console.log("An error occured while writing to file", err); 128 | return; 129 | } 130 | 131 | console.log("Written TOML to root"); 132 | return; 133 | } 134 | ); 135 | 136 | fs.writeFileSync( 137 | path.join(extensionsDir, "shopify.app.toml"), 138 | extensionStr, 139 | (err) => { 140 | if (err) { 141 | console.log("An error occured while writing to file", err); 142 | return; 143 | } 144 | 145 | console.log("Written TOML to extension"); 146 | return; 147 | } 148 | ); 149 | } 150 | } catch (e) { 151 | console.error("---> An error occured while writing toml files"); 152 | console.log(e.message); 153 | } 154 | -------------------------------------------------------------------------------- /_developer/types/toml.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * Most of the `Build` config is ommited on purpose since we're not using CLI to run dev 4 | * Read about config more on: 5 | * https://shopify.dev/docs/apps/tools/cli/configuration 6 | * 7 | */ 8 | 9 | /** 10 | * Configuration for the Shopify app 11 | * @typedef {Object} AppConfig 12 | * @property {string} name - The name of the application. 13 | * @property {string} handle - A unique handle for the application. 14 | * @property {string} client_id - The client ID for OAuth authentication. 15 | * @property {string[]} application_url - The base URL for the application. 16 | * @property {string[]} extension_directories - The location of extension directory. 17 | * @property {boolean} embedded - Indicates if the app is to be embedded within a platform. 18 | * 19 | * @property {AccessScopes} access_scopes - The access scopes required by the application. 20 | * @property {AccessConfig} access - The access scopes required by the application. 21 | * @property {AuthConfig} auth - Authentication configuration details. 22 | * @property {WebhooksConfig} webhooks - Configuration for webhooks. 23 | * @property {AppProxyConfig} app_proxy - Configuration for application proxy. 24 | * @property {POSConfig} pos - Point of Sale configuration. 25 | * @property {PreferencesConfig} preferences - App preferences configuration. 26 | * @property {BuildConfig} build - Build configuration. 27 | */ 28 | 29 | /** 30 | * Access scopes 31 | * @typedef {Object} AccessScopes 32 | * @property {string} scopes - The scopes required for accessing resources. 33 | * @property {string[]} optional_scopes - Optional access scopes for accessing resources. 34 | * @property {boolean} use_legacy_install_flow - Indicates if the legacy install flow should be used. 35 | */ 36 | 37 | /** 38 | * Access config for Shopify APIs 39 | * @typedef {Object} AccessConfig 40 | * @property {('online'|'offline')} direct_api_mode - Access mode that direct api access wil use. 41 | * @property {boolean} embedded_app_direct_api_access - Whether your embedded app has access to direct api access for calling admin Graphql APIs 42 | */ 43 | 44 | /** 45 | * Authentication configuration 46 | * @typedef {Object} AuthConfig 47 | * @property {string[]} redirect_urls - URLs to which the user can be redirected after authentication. 48 | */ 49 | 50 | /** 51 | * Webhook configuration 52 | * @typedef {Object} WebhooksConfig 53 | * @property {('2024-07' | '2024-10' | '2025-01' | '2025-04')} api_version - The API version to be used for webhooks. 54 | * @property {PrivacyComplianceConfig} privacy_compliance - Configuration for webhooks. 55 | */ 56 | 57 | /** 58 | * GDPR Strings 59 | * @typedef {Object} PrivacyComplianceConfig 60 | * @property {string} customer_deletion_url - GPDR route to customer deletion url 61 | * @property {string} customer_data_request_url - GPDR route to customer data request url 62 | * @property {string} shop_deletion_url - GPDR route to shop deletion url 63 | 64 | */ 65 | 66 | /** 67 | * App proxy 68 | * @typedef {Object} AppProxyConfig 69 | * @property {string} url - The base URL for the app proxy. 70 | * @property {string} subpath - The subpath at which the app proxy is accessible. 71 | * @property {('apps' | 'a' | 'community' | 'tools' )} prefix - The prefix used for the app proxy routes. 72 | */ z; 73 | 74 | /** 75 | * Point of Sale (POS) configuration 76 | * @typedef {Object} POSConfig 77 | * @property {boolean} embedded - Indicates if the POS app is to be embedded within a platform. 78 | */ 79 | 80 | /** 81 | * Preferences configuration 82 | * @typedef {Object} PreferencesConfig 83 | * @property {boolean} url - URL for your app's preferences page 84 | */ 85 | 86 | /** 87 | * Preferences configuration 88 | * @typedef {Object} BuildConfig 89 | * @property {boolean} include_config_on_deploy - Includes the toml file when deploying to Shopify 90 | */ 91 | 92 | export {}; 93 | -------------------------------------------------------------------------------- /_developer/types/webhookTopics.js: -------------------------------------------------------------------------------- 1 | //Ref: https://shopify.dev/docs/api/webhooks/2025-04?reference=toml 2 | /** 3 | * @typedef {Object} WebhookTopics 4 | * @property {Array<( 5 | * 'app/scopes_update' | 6 | * 'app/uninstalled' | 7 | * 'app_purchases_one_time/update' | 8 | * 'app_subscriptions/approaching_capped_amount' | 9 | * 'app_subscriptions/update' | 10 | * 'audit_events/admin_api_activity' | 11 | * 'bulk_operations/finish' | 12 | * 'carts/create' | 13 | * 'carts/update' | 14 | * 'channels/delete' | 15 | * 'checkout_and_accounts_configurations/update' | 16 | * 'checkouts/create' | 17 | * 'checkouts/delete' | 18 | * 'checkouts/update' | 19 | * 'collection_listings/add' | 20 | * 'collection_listings/remove' | 21 | * 'collection_listings/update' | 22 | * 'collection_publications/create' | 23 | * 'collection_publications/delete' | 24 | * 'collection_publications/update' | 25 | * 'collections/create' | 26 | * 'collections/delete' | 27 | * 'collections/update' | 28 | * 'companies/create' | 29 | * 'companies/delete' | 30 | * 'companies/update' | 31 | * 'company_contact_roles/assign' | 32 | * 'company_contact_roles/revoke' | 33 | * 'company_contacts/create' | 34 | * 'company_contacts/delete' | 35 | * 'company_contacts/update' | 36 | * 'company_locations/create' | 37 | * 'company_locations/delete' | 38 | * 'company_locations/update' | 39 | * 'customer.joined_segment' | 40 | * 'customer.left_segment' | 41 | * 'customer.tags_added' | 42 | * 'customer.tags_removed' | 43 | * 'customer_account_settings/update' | 44 | * 'customer_groups/create' | 45 | * 'customer_groups/delete' | 46 | * 'customer_groups/update' | 47 | * 'customer_payment_methods/create' | 48 | * 'customer_payment_methods/revoke' | 49 | * 'customer_payment_methods/update' | 50 | * 'customers/create' | 51 | * 'customers/data_request' | 52 | * 'customers/delete' | 53 | * 'customers/disable' | 54 | * 'customers/enable' | 55 | * 'customers/merge' | 56 | * 'customers/purchasing_summary' | 57 | * 'customers/redact' | 58 | * 'customers/update' | 59 | * 'customers_email_marketing_consent/update' | 60 | * 'customers_marketing_consent/update' | 61 | * 'delivery_promise_settings/update' | 62 | * 'discounts/create' | 63 | * 'discounts/delete' | 64 | * 'discounts/redeemcode_added' | 65 | * 'discounts/redeemcode_removed' | 66 | * 'discounts/update' | 67 | * 'disputes/create' | 68 | * 'disputes/update' | 69 | * 'domains/create' | 70 | * 'domains/destroy' | 71 | * 'domains/update' | 72 | * 'draft_orders/create' | 73 | * 'draft_orders/delete' | 74 | * 'draft_orders/update' | 75 | * 'finance_app_staff_member/delete' | 76 | * 'finance_app_staff_member/grant' | 77 | * 'finance_app_staff_member/revoke' | 78 | * 'finance_app_staff_member/update' | 79 | * 'finance_kyc_information/update' | 80 | * 'fulfillment_events/create' | 81 | * 'fulfillment_events/delete' | 82 | * 'fulfillment_holds/added' | 83 | * 'fulfillment_holds/released' | 84 | * 'fulfillment_orders/cancellation_request_accepted' | 85 | * 'fulfillment_orders/cancellation_request_rejected' | 86 | * 'fulfillment_orders/cancellation_request_submitted' | 87 | * 'fulfillment_orders/cancelled' | 88 | * 'fulfillment_orders/fulfillment_request_accepted' | 89 | * 'fulfillment_orders/fulfillment_request_rejected' | 90 | * 'fulfillment_orders/fulfillment_request_submitted' | 91 | * 'fulfillment_orders/fulfillment_service_failed_to_complete' | 92 | * 'fulfillment_orders/hold_released' | 93 | * 'fulfillment_orders/line_items_prepared_for_local_delivery' | 94 | * 'fulfillment_orders/line_items_prepared_for_pickup' | 95 | * 'fulfillment_orders/merged' | 96 | * 'fulfillment_orders/moved' | 97 | * 'fulfillment_orders/order_routing_complete' | 98 | * 'fulfillment_orders/placed_on_hold' | 99 | * 'fulfillment_orders/rescheduled' | 100 | * 'fulfillment_orders/scheduled_fulfillment_order_ready' | 101 | * 'fulfillment_orders/split' | 102 | * 'fulfillments/create' | 103 | * 'fulfillments/update' | 104 | * 'inventory_items/create' | 105 | * 'inventory_items/delete' | 106 | * 'inventory_items/update' | 107 | * 'inventory_levels/connect' | 108 | * 'inventory_levels/disconnect' | 109 | * 'inventory_levels/update' | 110 | * 'locales/create' | 111 | * 'locales/update' | 112 | * 'locations/activate' | 113 | * 'locations/create' | 114 | * 'locations/deactivate' | 115 | * 'locations/delete' | 116 | * 'locations/update' | 117 | * 'markets/create' | 118 | * 'markets/delete' | 119 | * 'markets/update' | 120 | * 'markets_backup_region/update' | 121 | * 'metafield_definitions/create' | 122 | * 'metafield_definitions/delete' | 123 | * 'metafield_definitions/update' | 124 | * 'metaobjects/create' | 125 | * 'metaobjects/delete' | 126 | * 'metaobjects/update' | 127 | * 'order_transactions/create' | 128 | * 'orders/cancelled' | 129 | * 'orders/create' | 130 | * 'orders/delete' | 131 | * 'orders/edited' | 132 | * 'orders/fulfilled' | 133 | * 'orders/paid' | 134 | * 'orders/partially_fulfilled' | 135 | * 'orders/risk_assessment_changed' | 136 | * 'orders/shopify_protect_eligibility_changed' | 137 | * 'orders/updated' | 138 | * 'payment_schedules/due' | 139 | * 'payment_terms/create' | 140 | * 'payment_terms/delete' | 141 | * 'payment_terms/update' | 142 | * 'product_feeds/create' | 143 | * 'product_feeds/full_sync' | 144 | * 'product_feeds/full_sync_finish' | 145 | * 'product_feeds/incremental_sync' | 146 | * 'product_feeds/update' | 147 | * 'product_listings/add' | 148 | * 'product_listings/remove' | 149 | * 'product_listings/update' | 150 | * 'product_publications/create' | 151 | * 'product_publications/delete' | 152 | * 'product_publications/update' | 153 | * 'products/create' | 154 | * 'products/delete' | 155 | * 'products/update' | 156 | * 'profiles/create' | 157 | * 'profiles/delete' | 158 | * 'profiles/update' | 159 | * 'refunds/create' | 160 | * 'returns/approve' | 161 | * 'returns/cancel' | 162 | * 'returns/close' | 163 | * 'returns/decline' | 164 | * 'returns/reopen' | 165 | * 'returns/request' | 166 | * 'returns/update' | 167 | * 'reverse_deliveries/attach_deliverable' | 168 | * 'reverse_fulfillment_orders/dispose' | 169 | * 'scheduled_product_listings/add' | 170 | * 'scheduled_product_listings/remove' | 171 | * 'scheduled_product_listings/update' | 172 | * 'segments/create' | 173 | * 'segments/delete' | 174 | * 'segments/update' | 175 | * 'selling_plan_groups/create' | 176 | * 'selling_plan_groups/delete' | 177 | * 'selling_plan_groups/update' | 178 | * 'shop/redact' | 179 | * 'shop/update' | 180 | * 'subscription_billing_attempts/challenged' | 181 | * 'subscription_billing_attempts/failure' | 182 | * 'subscription_billing_attempts/success' | 183 | * 'subscription_billing_cycle_edits/create' | 184 | * 'subscription_billing_cycle_edits/delete' | 185 | * 'subscription_billing_cycle_edits/update' | 186 | * 'subscription_billing_cycles/skip' | 187 | * 'subscription_billing_cycles/unskip' | 188 | * 'subscription_contracts/activate' | 189 | * 'subscription_contracts/cancel' | 190 | * 'subscription_contracts/create' | 191 | * 'subscription_contracts/expire' | 192 | * 'subscription_contracts/fail' | 193 | * 'subscription_contracts/pause' | 194 | * 'subscription_contracts/update' | 195 | * 'tender_transactions/create' | 196 | * 'themes/create' | 197 | * 'themes/delete' | 198 | * 'themes/publish' | 199 | * 'themes/update' | 200 | * 'variants/in_stock' | 201 | * 'variants/out_of_stock' 202 | * )>} topic - Topic of the webhook 203 | */ 204 | 205 | export {}; 206 | -------------------------------------------------------------------------------- /_developer/webhookWriter.js: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import path from "path"; 3 | import shopify from "../utils/shopify.js"; 4 | 5 | /** 6 | * @typedef {Object} ApiEndpoint 7 | * @property {string} topic - The API endpoint topic. 8 | * @property {string} graphql_topic - The topic's GraphQL topic name. 9 | */ 10 | 11 | //Ref: https://shopify.dev/docs/api/webhooks/2025-04?reference=toml 12 | /** 13 | * @type {ApiEndpoint[]} 14 | */ 15 | const availableTopics = [ 16 | { topic: "app/uninstalled", graphql_topic: "APP_UNINSTALLED" }, 17 | { topic: "app/scopes_update", graphql_topic: "APP_SCOPES_UPDATE" }, 18 | { 19 | topic: "app_purchases_one_time/update", 20 | graphql_topic: "APP_PURCHASES_ONE_TIME_UPDATE", 21 | }, 22 | { 23 | topic: "app_subscriptions/approaching_capped_amount", 24 | graphql_topic: "APP_SUBSCRIPTIONS_APPROACHING_CAPPED_AMOUNT", 25 | }, 26 | { 27 | topic: "app_subscriptions/update", 28 | graphql_topic: "APP_SUBSCRIPTIONS_UPDATE", 29 | }, 30 | { 31 | topic: "audit_events/admin_api_activity", 32 | graphql_topic: "AUDIT_EVENTS_ADMIN_API_ACTIVITY", 33 | }, 34 | { topic: "bulk_operations/finish", graphql_topic: "BULK_OPERATIONS_FINISH" }, 35 | { topic: "carts/create", graphql_topic: "CARTS_CREATE" }, 36 | { topic: "carts/update", graphql_topic: "CARTS_UPDATE" }, 37 | { topic: "channels/delete", graphql_topic: "CHANNELS_DELETE" }, 38 | { 39 | topic: "CHECKOUT_AND_ACCOUNTS_CONFIGURATIONS_UPDATE", 40 | graphql_topic: "CHECKOUT_AND_ACCOUNTS_CONFIGURATIONS_UPDATE", 41 | }, 42 | { topic: "checkouts/create", graphql_topic: "CHECKOUTS_CREATE" }, 43 | { topic: "checkouts/delete", graphql_topic: "CHECKOUTS_DELETE" }, 44 | { topic: "checkouts/update", graphql_topic: "CHECKOUTS_UPDATE" }, 45 | { 46 | topic: "collection_listings/add", 47 | graphql_topic: "COLLECTION_LISTINGS_ADD", 48 | }, 49 | { 50 | topic: "collection_listings/remove", 51 | graphql_topic: "COLLECTION_LISTINGS_REMOVE", 52 | }, 53 | { 54 | topic: "collection_listings/update", 55 | graphql_topic: "COLLECTION_LISTINGS_UPDATE", 56 | }, 57 | { 58 | topic: "collection_publications/create", 59 | graphql_topic: "COLLECTION_PUBLICATIONS_CREATE", 60 | }, 61 | { 62 | topic: "collection_publications/delete", 63 | graphql_topic: "COLLECTION_PUBLICATIONS_DELETE", 64 | }, 65 | { 66 | topic: "collection_publications/update", 67 | graphql_topic: "COLLECTION_PUBLICATIONS_UPDATE", 68 | }, 69 | { topic: "collections/create", graphql_topic: "COLLECTIONS_CREATE" }, 70 | { topic: "collections/delete", graphql_topic: "COLLECTIONS_DELETE" }, 71 | { topic: "collections/update", graphql_topic: "COLLECTIONS_UPDATE" }, 72 | { topic: "companies/create", graphql_topic: "COMPANIES_CREATE" }, 73 | { topic: "companies/delete", graphql_topic: "COMPANIES_DELETE" }, 74 | { topic: "companies/update", graphql_topic: "COMPANIES_UPDATE" }, 75 | { 76 | topic: "company_contact_roles/assign", 77 | graphql_topic: "COMPANY_CONTACT_ROLES_ASSIGN", 78 | }, 79 | { 80 | topic: "company_contact_roles/revoke", 81 | graphql_topic: "COMPANY_CONTACT_ROLES_REVOKE", 82 | }, 83 | { 84 | topic: "company_contacts/create", 85 | graphql_topic: "COMPANY_CONTACTS_CREATE", 86 | }, 87 | { 88 | topic: "company_contacts/delete", 89 | graphql_topic: "COMPANY_CONTACTS_DELETE", 90 | }, 91 | { 92 | topic: "company_contacts/update", 93 | graphql_topic: "COMPANY_CONTACTS_UPDATE", 94 | }, 95 | { 96 | topic: "company_locations/create", 97 | graphql_topic: "COMPANY_LOCATIONS_CREATE", 98 | }, 99 | { 100 | topic: "company_locations/delete", 101 | graphql_topic: "COMPANY_LOCATIONS_DELETE", 102 | }, 103 | { 104 | topic: "company_locations/update", 105 | graphql_topic: "COMPANY_LOCATIONS_UPDATE", 106 | }, 107 | { 108 | topic: "customer.joined_segment", 109 | graphql_topic: "CUSTOMER_JOINED_SEGMENT", 110 | }, 111 | { topic: "customer.left_segment", graphql_topic: "CUSTOMER_LEFT_SEGMENT" }, 112 | { topic: "customer.tags_added", graphql_topic: "CUSTOMER_TAGS_ADDED" }, 113 | { topic: "customer.tags_removed", graphql_topic: "CUSTOMER_TAGS_REMOVED" }, 114 | { 115 | topic: "customer_account_settings/update", 116 | graphql_topic: "CUSTOMER_ACCOUNT_SETTINGS_UPDATE", 117 | }, 118 | { topic: "customer_groups/create", graphql_topic: "CUSTOMER_GROUPS_CREATE" }, 119 | { topic: "customer_groups/delete", graphql_topic: "CUSTOMER_GROUPS_DELETE" }, 120 | { topic: "customer_groups/update", graphql_topic: "CUSTOMER_GROUPS_UPDATE" }, 121 | { 122 | topic: "customer_payment_methods/create", 123 | graphql_topic: "CUSTOMER_PAYMENT_METHODS_CREATE", 124 | }, 125 | { 126 | topic: "customer_payment_methods/revoke", 127 | graphql_topic: "CUSTOMER_PAYMENT_METHODS_REVOKE", 128 | }, 129 | { 130 | topic: "customer_payment_methods/update", 131 | graphql_topic: "CUSTOMER_PAYMENT_METHODS_UPDATE", 132 | }, 133 | { topic: "customers/create", graphql_topic: "CUSTOMERS_CREATE" }, 134 | { topic: "customers/data_request", graphql_topic: "CUSTOMERS_DATA_REQUEST" }, 135 | { topic: "customers/delete", graphql_topic: "CUSTOMERS_DELETE" }, 136 | { topic: "customers/disable", graphql_topic: "CUSTOMERS_DISABLE" }, 137 | { topic: "customers/enable", graphql_topic: "CUSTOMERS_ENABLE" }, 138 | { topic: "customers/merge", graphql_topic: "CUSTOMERS_MERGE" }, 139 | { 140 | topic: "customers/purchasing_summary", 141 | graphql_topic: "CUSTOMERS_PURCHASING_SUMMARY", 142 | }, 143 | { topic: "customers/redact", graphql_topic: "CUSTOMERS_REDACT" }, 144 | { topic: "customers/update", graphql_topic: "CUSTOMERS_UPDATE" }, 145 | { 146 | topic: "customers_email_marketing_consent/update", 147 | graphql_topic: "CUSTOMERS_EMAIL_MARKETING_CONSENT_UPDATE", 148 | }, 149 | { 150 | topic: "delivery_promise_settings/update", 151 | graphql_topic: "DELIVERY_PROMISE_SETTINGS_UPDATE", 152 | }, 153 | { 154 | topic: "customers_marketing_consent/update", 155 | graphql_topic: "CUSTOMERS_MARKETING_CONSENT_UPDATE", 156 | }, 157 | { topic: "discounts/create", graphql_topic: "DISCOUNTS_CREATE" }, 158 | { topic: "discounts/delete", graphql_topic: "DISCOUNTS_DELETE" }, 159 | { 160 | topic: "discounts/redeemcode_added", 161 | graphql_topic: "DISCOUNTS_REDEEMCODE_ADDED", 162 | }, 163 | { 164 | topic: "discounts/redeemcode_removed", 165 | graphql_topic: "DISCOUNTS_REDEEMCODE_REMOVED", 166 | }, 167 | { topic: "discounts/update", graphql_topic: "DISCOUNTS_UPDATE" }, 168 | { topic: "disputes/create", graphql_topic: "DISPUTES_CREATE" }, 169 | { topic: "disputes/update", graphql_topic: "DISPUTES_UPDATE" }, 170 | { topic: "domains/create", graphql_topic: "DOMAINS_CREATE" }, 171 | { topic: "domains/destroy", graphql_topic: "DOMAINS_DESTROY" }, 172 | { topic: "domains/update", graphql_topic: "DOMAINS_UPDATE" }, 173 | { topic: "draft_orders/create", graphql_topic: "DRAFT_ORDERS_CREATE" }, 174 | { topic: "draft_orders/delete", graphql_topic: "DRAFT_ORDERS_DELETE" }, 175 | { topic: "draft_orders/update", graphql_topic: "DRAFT_ORDERS_UPDATE" }, 176 | { 177 | topic: "finance_app_staff_member/delete", 178 | graphql_topic: "FINANCE_APP_STAFF_MEMBER_DELETE", 179 | }, 180 | { 181 | topic: "finance_app_staff_member/grant", 182 | graphql_topic: "FINANCE_APP_STAFF_MEMBER_DELETE", 183 | }, 184 | { 185 | topic: "finance_app_staff_member/revoke", 186 | graphql_topic: "FINANCE_APP_STAFF_MEMBER_DELETE", 187 | }, 188 | { 189 | topic: "finance_app_staff_member/update", 190 | graphql_topic: "FINANCE_APP_STAFF_MEMBER_DELETE", 191 | }, 192 | { 193 | topic: "finance_kyc_information/update", 194 | graphql_topic: "FINANCE_APP_STAFF_MEMBER_DELETE", 195 | }, 196 | { 197 | topic: "fulfillment_events/create", 198 | graphql_topic: "FULFILLMENT_EVENTS_CREATE", 199 | }, 200 | { 201 | topic: "fulfillment_events/delete", 202 | graphql_topic: "FULFILLMENT_EVENTS_DELETE", 203 | }, 204 | { 205 | topic: "fulfillment_holds/added", 206 | graphql_topic: "FULFILLMENT_HOLDS_ADDED", 207 | }, 208 | { 209 | topic: "fulfillment_events/released", 210 | graphql_topic: "FULFILLMENT_HOLDS_RELEASED", 211 | }, 212 | { 213 | topic: "fulfillment_orders/cancellation_request_accepted", 214 | graphql_topic: "FULFILLMENT_ORDERS_CANCELLATION_REQUEST_ACCEPTED", 215 | }, 216 | { 217 | topic: "fulfillment_orders/cancellation_request_rejected", 218 | graphql_topic: "FULFILLMENT_ORDERS_CANCELLATION_REQUEST_REJECTED", 219 | }, 220 | { 221 | topic: "fulfillment_orders/cancellation_request_submitted", 222 | graphql_topic: "FULFILLMENT_ORDERS_CANCELLATION_REQUEST_SUBMITTED", 223 | }, 224 | { 225 | topic: "fulfillment_orders/cancelled", 226 | graphql_topic: "FULFILLMENT_ORDERS_CANCELLED", 227 | }, 228 | { 229 | topic: "fulfillment_orders/fulfillment_request_accepted", 230 | graphql_topic: "FULFILLMENT_ORDERS_FULFILLMENT_REQUEST_ACCEPTED", 231 | }, 232 | { 233 | topic: "fulfillment_orders/fulfillment_request_rejected", 234 | graphql_topic: "FULFILLMENT_ORDERS_FULFILLMENT_REQUEST_REJECTED", 235 | }, 236 | { 237 | topic: "fulfillment_orders/fulfillment_request_submitted", 238 | graphql_topic: "FULFILLMENT_ORDERS_FULFILLMENT_REQUEST_SUBMITTED", 239 | }, 240 | { 241 | topic: "fulfillment_orders/fulfillment_service_failed_to_complete", 242 | graphql_topic: "FULFILLMENT_ORDERS_FULFILLMENT_SERVICE_FAILED_TO_COMPLETE", 243 | }, 244 | { 245 | topic: "fulfillment_orders/hold_released", 246 | graphql_topic: "FULFILLMENT_ORDERS_HOLD_RELEASED", 247 | }, 248 | { 249 | topic: "fulfillment_orders/line_items_prepared_for_local_delivery", 250 | graphql_topic: "FULFILLMENT_ORDERS_LINE_ITEMS_PREPARED_FOR_LOCAL_DELIVERY", 251 | }, 252 | { 253 | topic: "fulfillment_orders/line_items_prepared_for_pickup", 254 | graphql_topic: "FULFILLMENT_ORDERS_LINE_ITEMS_PREPARED_FOR_PICKUP", 255 | }, 256 | { 257 | topic: "fulfillment_orders/merged", 258 | graphql_topic: "FULFILLMENT_ORDERS_MERGED", 259 | }, 260 | { 261 | topic: "fulfillment_orders/moved", 262 | graphql_topic: "FULFILLMENT_ORDERS_MOVED", 263 | }, 264 | { 265 | topic: "fulfillment_orders/order_routing_complete", 266 | graphql_topic: "FULFILLMENT_ORDERS_ORDER_ROUTING_COMPLETE", 267 | }, 268 | { 269 | topic: "fulfillment_orders/placed_on_hold", 270 | graphql_topic: "FULFILLMENT_ORDERS_PLACED_ON_HOLD", 271 | }, 272 | { 273 | topic: "fulfillment_orders/rescheduled", 274 | graphql_topic: "FULFILLMENT_ORDERS_RESCHEDULED", 275 | }, 276 | { 277 | topic: "fulfillment_orders/scheduled_fulfillment_order_ready", 278 | graphql_topic: "FULFILLMENT_ORDERS_SCHEDULED_FULFILLMENT_ORDER_READY", 279 | }, 280 | { 281 | topic: "fulfillment_orders/split", 282 | graphql_topic: "FULFILLMENT_ORDERS_SPLIT", 283 | }, 284 | { topic: "fulfillments/create", graphql_topic: "FULFILLMENTS_CREATE" }, 285 | { topic: "fulfillments/update", graphql_topic: "FULFILLMENTS_UPDATE" }, 286 | { topic: "inventory_items/create", graphql_topic: "INVENTORY_ITEMS_CREATE" }, 287 | { topic: "inventory_items/delete", graphql_topic: "INVENTORY_ITEMS_DELETE" }, 288 | { topic: "inventory_items/update", graphql_topic: "INVENTORY_ITEMS_UPDATE" }, 289 | { 290 | topic: "inventory_levels/connect", 291 | graphql_topic: "INVENTORY_LEVELS_CONNECT", 292 | }, 293 | { 294 | topic: "inventory_levels/disconnect", 295 | graphql_topic: "INVENTORY_LEVELS_DISCONNECT", 296 | }, 297 | { 298 | topic: "inventory_levels/update", 299 | graphql_topic: "INVENTORY_LEVELS_UPDATE", 300 | }, 301 | { topic: "locales/create", graphql_topic: "LOCALES_CREATE" }, 302 | { topic: "locales/update", graphql_topic: "LOCALES_UPDATE" }, 303 | { topic: "locations/activate", graphql_topic: "LOCATIONS_ACTIVATE" }, 304 | { topic: "locations/create", graphql_topic: "LOCATIONS_CREATE" }, 305 | { topic: "locations/deactivate", graphql_topic: "LOCATIONS_DEACTIVATE" }, 306 | { topic: "locations/delete", graphql_topic: "LOCATIONS_DELETE" }, 307 | { topic: "locations/update", graphql_topic: "LOCATIONS_UPDATE" }, 308 | { topic: "markets/create", graphql_topic: "MARKETS_CREATE" }, 309 | { topic: "markets/delete", graphql_topic: "MARKETS_DELETE" }, 310 | { topic: "markets/update", graphql_topic: "MARKETS_UPDATE" }, 311 | { 312 | topic: "metafield_definitions/create", 313 | graphql_topic: "METAFIELD_DEFINITIONS_CREATE", 314 | }, 315 | { 316 | topic: "metafield_definitions/delete", 317 | graphql_topic: "METAFIELD_DEFINITIONS_DELETE", 318 | }, 319 | { 320 | topic: "metafield_definitions/update", 321 | graphql_topic: "METAFIELD_DEFINITIONS_UPDATE", 322 | }, 323 | { topic: "metaobjects/create", graphql_topic: "METAOBJECTS_CREATE" }, 324 | { topic: "metaobjects/delete", graphql_topic: "METAOBJECTS_DELETE" }, 325 | { topic: "metaobjects/update", graphql_topic: "METAOBJECTS_UPDATE" }, 326 | { 327 | topic: "order_transactions/create", 328 | graphql_topic: "ORDER_TRANSACTIONS_CREATE", 329 | }, 330 | { topic: "orders/cancelled", graphql_topic: "ORDERS_CANCELLED" }, 331 | { topic: "orders/create", graphql_topic: "ORDERS_CREATE" }, 332 | { topic: "orders/delete", graphql_topic: "ORDERS_DELETE" }, 333 | { topic: "orders/edited", graphql_topic: "ORDERS_EDITED" }, 334 | { topic: "orders/fulfilled", graphql_topic: "ORDERS_FULFILLED" }, 335 | { topic: "orders/paid", graphql_topic: "ORDERS_PAID" }, 336 | { 337 | topic: "orders/partially_fulfilled", 338 | graphql_topic: "ORDERS_PARTIALLY_FULFILLED", 339 | }, 340 | { 341 | topic: "orders/risk_assessment_changed", 342 | graphql_topic: "ORDERS_RISK_ASSESSMENT_CHANGED", 343 | }, 344 | { 345 | topic: "orders/shopify_protect_eligibility_changed", 346 | graphql_topic: "ORDERS_SHOPIFY_PROTECT_ELIGIBILITY_CHANGED", 347 | }, 348 | { topic: "orders/updated", graphql_topic: "ORDERS_UPDATED" }, 349 | { topic: "payment_schedules/due", graphql_topic: "PAYMENT_SCHEDULES_DUE" }, 350 | { topic: "payment_terms/create", graphql_topic: "PAYMENT_TERMS_CREATE" }, 351 | { topic: "payment_terms/delete", graphql_topic: "PAYMENT_TERMS_DELETE" }, 352 | { topic: "payment_terms/update", graphql_topic: "PAYMENT_TERMS_UPDATE" }, 353 | { topic: "product_feeds/create", graphql_topic: "PRODUCT_FEEDS_CREATE" }, 354 | { 355 | topic: "product_feeds/full_sync", 356 | graphql_topic: "PRODUCT_FEEDS_FULL_SYNC", 357 | }, 358 | { 359 | topic: "product_feeds/full_sync_finish", 360 | graphql_topic: "PRODUCT_FEEDS_FULL_SYNC_FINISH", 361 | }, 362 | { 363 | topic: "product_feeds/incremental_sync", 364 | graphql_topic: "PRODUCT_FEEDS_INCREMENTAL_SYNC", 365 | }, 366 | { topic: "product_feeds/update", graphql_topic: "PRODUCT_FEEDS_UPDATE" }, 367 | { topic: "product_listings/add", graphql_topic: "PRODUCT_LISTINGS_ADD" }, 368 | { 369 | topic: "product_listings/remove", 370 | graphql_topic: "PRODUCT_LISTINGS_REMOVE", 371 | }, 372 | { 373 | topic: "product_listings/update", 374 | graphql_topic: "PRODUCT_LISTINGS_UPDATE", 375 | }, 376 | { 377 | topic: "product_publications/create", 378 | graphql_topic: "PRODUCT_PUBLICATIONS_CREATE", 379 | }, 380 | { 381 | topic: "product_publications/delete", 382 | graphql_topic: "PRODUCT_PUBLICATIONS_DELETE", 383 | }, 384 | { 385 | topic: "product_publications/update", 386 | graphql_topic: "PRODUCT_PUBLICATIONS_UPDATE", 387 | }, 388 | { topic: "products/create", graphql_topic: "PRODUCTS_CREATE" }, 389 | { topic: "products/delete", graphql_topic: "PRODUCTS_DELETE" }, 390 | { topic: "products/update", graphql_topic: "PRODUCTS_UPDATE" }, 391 | { topic: "profiles/create", graphql_topic: "PROFILES_CREATE" }, 392 | { topic: "profiles/delete", graphql_topic: "PROFILES_DELETE" }, 393 | { topic: "profiles/update", graphql_topic: "PROFILES_UPDATE" }, 394 | { topic: "refunds/create", graphql_topic: "REFUNDS_CREATE" }, 395 | { topic: "returns/approve", graphql_topic: "RETURNS_APPROVE" }, 396 | { topic: "returns/cancel", graphql_topic: "RETURNS_CANCEL" }, 397 | { topic: "returns/close", graphql_topic: "RETURNS_CLOSE" }, 398 | { topic: "returns/decline", graphql_topic: "RETURNS_DECLINE" }, 399 | { topic: "returns/reopen", graphql_topic: "RETURNS_REOPEN" }, 400 | { topic: "returns/request", graphql_topic: "RETURNS_REQUEST" }, 401 | { topic: "returns/update", graphql_topic: "RETURNS_UPDATE" }, 402 | { 403 | topic: "reverse_deliveries/attach_deliverable", 404 | graphql_topic: "REVERSE_DELIVERIES_ATTACH_DELIVERABLE", 405 | }, 406 | { 407 | topic: "reverse_fulfillment_orders/dispose", 408 | graphql_topic: "REVERSE_FULFILLMENT_ORDERS_DISPOSE", 409 | }, 410 | { 411 | topic: "scheduled_product_listings/add", 412 | graphql_topic: "SCHEDULED_PRODUCT_LISTINGS_ADD", 413 | }, 414 | { 415 | topic: "scheduled_product_listings/remove", 416 | graphql_topic: "SCHEDULED_PRODUCT_LISTINGS_REMOVE", 417 | }, 418 | { 419 | topic: "scheduled_product_listings/update", 420 | graphql_topic: "SCHEDULED_PRODUCT_LISTINGS_UPDATE", 421 | }, 422 | { topic: "segments/create", graphql_topic: "SEGMENTS_CREATE" }, 423 | { topic: "segments/delete", graphql_topic: "SEGMENTS_DELETE" }, 424 | { topic: "segments/update", graphql_topic: "SEGMENTS_UPDATE" }, 425 | { 426 | topic: "selling_plan_groups/create", 427 | graphql_topic: "SELLING_PLAN_GROUPS_CREATE", 428 | }, 429 | { 430 | topic: "selling_plan_groups/delete", 431 | graphql_topic: "SELLING_PLAN_GROUPS_DELETE", 432 | }, 433 | { 434 | topic: "selling_plan_groups/update", 435 | graphql_topic: "SELLING_PLAN_GROUPS_UPDATE", 436 | }, 437 | { topic: "shop/redact", graphql_topic: "SHOP_REDACT" }, 438 | { topic: "shop/update", graphql_topic: "SHOP_UPDATE" }, 439 | { 440 | topic: "subscription_billing_attempts/challenged", 441 | graphql_topic: "SUBSCRIPTION_BILLING_ATTEMPTS_CHALLENGED", 442 | }, 443 | { 444 | topic: "subscription_billing_attempts/failure", 445 | graphql_topic: "SUBSCRIPTION_BILLING_ATTEMPTS_FAILURE", 446 | }, 447 | { 448 | topic: "subscription_billing_attempts/success", 449 | graphql_topic: "SUBSCRIPTION_BILLING_ATTEMPTS_SUCCESS", 450 | }, 451 | { 452 | topic: "subscription_billing_cycle_edits/create", 453 | graphql_topic: "SUBSCRIPTION_BILLING_CYCLE_EDITS_CREATE", 454 | }, 455 | { 456 | topic: "subscription_billing_cycle_edits/delete", 457 | graphql_topic: "SUBSCRIPTION_BILLING_CYCLE_EDITS_DELETE", 458 | }, 459 | { 460 | topic: "subscription_billing_cycle_edits/update", 461 | graphql_topic: "SUBSCRIPTION_BILLING_CYCLE_EDITS_UPDATE", 462 | }, 463 | { 464 | topic: "subscription_billing_cycles/skip", 465 | graphql_topic: "SUBSCRIPTION_BILLING_CYCLES_SKIP", 466 | }, 467 | { 468 | topic: "subscription_billing_cycles/unskip", 469 | graphql_topic: "SUBSCRIPTION_BILLING_CYCLES_UNSKIP", 470 | }, 471 | { 472 | topic: "subscription_contracts/activate", 473 | graphql_topic: "SUBSCRIPTION_CONTRACTS_ACTIVATE", 474 | }, 475 | { 476 | topic: "subscription_contracts/cancel", 477 | graphql_topic: "SUBSCRIPTION_CONTRACTS_CANCEL", 478 | }, 479 | { 480 | topic: "subscription_contracts/create", 481 | graphql_topic: "SUBSCRIPTION_CONTRACTS_CREATE", 482 | }, 483 | { 484 | topic: "subscription_contracts/expire", 485 | graphql_topic: "SUBSCRIPTION_CONTRACTS_EXPIRE", 486 | }, 487 | { 488 | topic: "subscription_contracts/fail", 489 | graphql_topic: "SUBSCRIPTION_CONTRACTS_FAIL", 490 | }, 491 | { 492 | topic: "subscription_contracts/pause", 493 | graphql_topic: "SUBSCRIPTION_CONTRACTS_PAUSE", 494 | }, 495 | { 496 | topic: "subscription_contracts/update", 497 | graphql_topic: "SUBSCRIPTION_CONTRACTS_UPDATE", 498 | }, 499 | { 500 | topic: "tender_transactions/create", 501 | graphql_topic: "TENDER_TRANSACTIONS_CREATE", 502 | }, 503 | { topic: "themes/create", graphql_topic: "THEMES_CREATE" }, 504 | { topic: "themes/delete", graphql_topic: "THEMES_DELETE" }, 505 | { topic: "themes/publish", graphql_topic: "THEMES_PUBLISH" }, 506 | { topic: "themes/update", graphql_topic: "THEMES_UPDATE" }, 507 | { topic: "variants/in_stock", graphql_topic: "VARIANTS_IN_STOCK" }, 508 | { topic: "variants/out_of_stock", graphql_topic: "VARIANTS_OUT_OF_STOCK" }, 509 | ]; 510 | 511 | const webhookWriter = (config) => { 512 | let subscriptionsArray = []; 513 | for (const entry in shopify.user.webhooks) { 514 | const subscription = { 515 | topics: shopify.user.webhooks[entry].topics, 516 | uri: shopify.user.webhooks[entry].url.startsWith("/api/webhooks/") 517 | ? `${process.env.SHOPIFY_APP_URL}${shopify.user.webhooks[entry].url}` 518 | : shopify.user.webhooks[entry].url, 519 | }; 520 | 521 | if (shopify.user.webhooks[entry].include_fields) { 522 | subscription.include_fields = shopify.user.webhooks[entry].include_fields; 523 | } 524 | 525 | if (shopify.user.webhooks[entry].filter) { 526 | subscription.filter = shopify.user.webhooks[entry].filter; 527 | } 528 | 529 | subscriptionsArray.push(subscription); 530 | } 531 | 532 | config.webhooks.subscriptions = [...subscriptionsArray]; 533 | 534 | writeToApi(); 535 | }; 536 | 537 | const shopifyFilePath = path.join(process.cwd(), "utils", "shopify.js"); 538 | const webhookHandlerFilePath = path.join( 539 | process.cwd(), 540 | "server", 541 | "webhooks", 542 | "_index.js" 543 | ); 544 | 545 | async function writeToApi() { 546 | try { 547 | const shopifyFileContent = fs.readFileSync(shopifyFilePath, "utf8"); 548 | const webhookImports = shopifyFileContent.match( 549 | /import .* from "\.\.\/server\/webhooks\/.*";/g 550 | ); 551 | 552 | let webhookHandlerFileContent = fs.readFileSync( 553 | webhookHandlerFilePath, 554 | "utf8" 555 | ); 556 | 557 | const topComment = `/** 558 | * DO NOT EDIT THIS FILE DIRECTLY 559 | * Head over to utils/shopify.js to create your webhooks 560 | * and write your webhook functions in server/webhooks. 561 | * If you don't know the format, use the \`createwebhook\` snippet when using VSCode 562 | * to get a boilerplate function for webhooks. 563 | * To update this file, run \`npm run update:config\` or \`bun run update:config\` 564 | */\n\n`; 565 | 566 | // Start with a fresh file containing shopify import first 567 | let newFileContent = 568 | topComment + 'import shopify from "../../utils/shopify.js";\n'; 569 | 570 | // Add other imports from webhook handlers if they exist 571 | if (webhookImports) { 572 | const uniqueImports = [...new Set(webhookImports)]; 573 | uniqueImports.forEach((importStatement) => { 574 | const formattedImportStatement = importStatement.replace( 575 | "../server/webhooks", 576 | "." 577 | ); 578 | newFileContent += formattedImportStatement + "\n"; 579 | }); 580 | } 581 | 582 | // Get the rest of the file content after the imports 583 | const mainContent = webhookHandlerFileContent.replace( 584 | /^([\s\S]*?^import[^\n]*\n)+/m, 585 | "" 586 | ); 587 | 588 | // Combine everything 589 | webhookHandlerFileContent = newFileContent + mainContent; 590 | 591 | // Check for duplicate topics 592 | const topicCounts = {}; 593 | shopify.user.webhooks.forEach((webhook) => { 594 | webhook.topics.forEach((topic) => { 595 | topicCounts[topic] = (topicCounts[topic] || 0) + 1; 596 | }); 597 | }); 598 | 599 | const hasDuplicateTopics = Object.values(topicCounts).some( 600 | (count) => count > 1 601 | ); 602 | 603 | // Generate the switch/case statement 604 | let switchCaseStatement = hasDuplicateTopics 605 | ? "switch (req.path) {\n" 606 | : "switch (validateWebhook.topic) {\n"; 607 | 608 | for (const entry of shopify.user.webhooks) { 609 | if (entry.url.startsWith("/api/webhooks")) { 610 | const handlerName = entry.callback.name; 611 | if (hasDuplicateTopics) { 612 | switchCaseStatement += ` case "${entry.url}":\n`; 613 | switchCaseStatement += ` await ${handlerName}(topic, shop, req.body, webhookId, apiVersion);\n`; 614 | switchCaseStatement += ` break;\n`; 615 | } else { 616 | entry.topics.forEach((topic, index) => { 617 | const topicCase = 618 | availableTopics.find((t) => t.topic === topic)?.graphql_topic || 619 | topic.toUpperCase().replace("/", "_"); 620 | switchCaseStatement += ` case "${topicCase}":\n`; 621 | if (index === entry.topics.length - 1) { 622 | switchCaseStatement += ` await ${handlerName}(topic, shop, req.body, webhookId, apiVersion);\n`; 623 | switchCaseStatement += ` break;\n`; 624 | } 625 | }); 626 | } 627 | } 628 | } 629 | switchCaseStatement += ` default:\n`; 630 | switchCaseStatement += ` throw new Error(\`Can't find a handler for \${${ 631 | hasDuplicateTopics ? "req.path" : "validateWebhook.topic" 632 | }}\`);\n`; 633 | switchCaseStatement += "}\n"; 634 | 635 | // Replace the existing switch/case statement 636 | const switchCaseRegex = /\/\/SWITCHCASE\n[\s\S]*?\/\/SWITCHCASE END/; 637 | webhookHandlerFileContent = webhookHandlerFileContent.replace( 638 | switchCaseRegex, 639 | `//SWITCHCASE\n${switchCaseStatement}//SWITCHCASE END` 640 | ); 641 | 642 | fs.writeFileSync(webhookHandlerFilePath, webhookHandlerFileContent, "utf8"); 643 | } catch (error) { 644 | console.error("Error writing to webhookHandler file:", error); 645 | } 646 | } 647 | 648 | export default webhookWriter; 649 | -------------------------------------------------------------------------------- /client/App.jsx: -------------------------------------------------------------------------------- 1 | import { AppProvider as PolarisProvider } from "@shopify/polaris"; 2 | import "@shopify/polaris/build/esm/styles.css"; 3 | import translations from "@shopify/polaris/locales/en.json"; 4 | import { useRoutes } from "raviger"; 5 | import routes from "./Routes"; 6 | import AppBridgeProvider from "./providers/AppBridgeProvider"; 7 | 8 | export default function App() { 9 | const RouteComponents = useRoutes(routes); 10 | 11 | return ( 12 | 13 | 14 | 15 | Fetch Data 16 | Billing API 17 | 18 | {RouteComponents} 19 | 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /client/Routes.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Index from "./pages/Index"; 3 | import BillingAPI from "./pages/debug/Billing"; 4 | import GetData from "./pages/debug/Data"; 5 | import DebugIndex from "./pages/debug/Index"; 6 | import OptionalScopes from "./pages/debug/Scopes"; 7 | 8 | const routes = { 9 | "/": () => , 10 | "/debug": () => , 11 | "/debug/scopes": () => , 12 | "/debug/billing": () => , 13 | "/debug/data": () => , 14 | }; 15 | 16 | export default routes; 17 | -------------------------------------------------------------------------------- /client/components/.gitkeep: -------------------------------------------------------------------------------- 1 | Empty file to have the components folder. Delete this file. 2 | -------------------------------------------------------------------------------- /client/entry-client.jsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from "react-dom/client"; 2 | import App from "./App"; 3 | 4 | const root = createRoot(document.getElementById("shopify-app")); 5 | root.render(); 6 | -------------------------------------------------------------------------------- /client/hooks/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kinngh/shopify-node-express-mongodb-app/ccfe6926569e1d92d667727e8f9619953d04bbbb/client/hooks/.gitkeep -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /client/pages/Index.jsx: -------------------------------------------------------------------------------- 1 | import { 2 | BlockStack, 3 | Button, 4 | Card, 5 | InlineStack, 6 | Layout, 7 | Page, 8 | Text, 9 | } from "@shopify/polaris"; 10 | import { ExternalIcon } from "@shopify/polaris-icons"; 11 | import { navigate } from "raviger"; 12 | 13 | const HomePage = () => { 14 | return ( 15 | <> 16 | 17 | 18 | 19 | 20 | 21 | 22 | Debug Cards are here and it now works 23 | 24 | 25 | Explore how the repository handles data fetching from the 26 | backend, App Proxy, making GraphQL requests, Billing API and 27 | more. 28 | 29 | 30 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | App Bridge CDN 47 | 48 | 49 | App Bridge has changed. Read more about it in the docs 50 | 51 | 52 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | Repository 74 | 75 | 76 | Found a bug? Open an issue on the repository, or star on 77 | GitHub 78 | 79 | 80 | 92 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | Course 114 | 115 | 116 | [BETA] I'm building course as a live service on How To Build 117 | Shopify Apps 118 | 119 | 120 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | ); 142 | }; 143 | 144 | export default HomePage; 145 | -------------------------------------------------------------------------------- /client/pages/debug/Billing.jsx: -------------------------------------------------------------------------------- 1 | import { 2 | BlockStack, 3 | Button, 4 | Card, 5 | DataTable, 6 | InlineStack, 7 | Layout, 8 | Page, 9 | Text, 10 | } from "@shopify/polaris"; 11 | import { navigate } from "raviger"; 12 | import { useEffect, useState } from "react"; 13 | 14 | const BillingAPI = () => { 15 | const [responseData, setResponseData] = useState(""); 16 | 17 | async function fetchContent() { 18 | setResponseData("loading..."); 19 | const res = await fetch("/api/apps/debug/createNewSubscription"); 20 | const data = await res.json(); 21 | if (data.error) { 22 | setResponseData(data.error); 23 | } else if (data.confirmationUrl) { 24 | setResponseData("Redirecting"); 25 | const { confirmationUrl } = data; 26 | open(confirmationUrl, "_top"); 27 | } 28 | } 29 | 30 | return ( 31 | navigate("/debug") }} 34 | > 35 | 36 | 37 | 38 | 39 | 40 | Subscribe your merchant to a test $10.25 plan and redirect to 41 | your home page. 42 | 43 | 44 | { 45 | /* If we have an error, it'll pop up here. */ 46 | responseData && {responseData} 47 | } 48 | 49 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | ); 67 | }; 68 | 69 | const ActiveSubscriptions = () => { 70 | const [rows, setRows] = useState([]); 71 | 72 | async function getActiveSubscriptions() { 73 | const res = await fetch("/api/apps/debug/getActiveSubscriptions"); 74 | const data = await res.json(); 75 | 76 | let rowsData = []; 77 | const activeSubscriptions = data.data.appInstallation.activeSubscriptions; 78 | 79 | if (activeSubscriptions.length === 0) { 80 | rowsData.push(["No Plan", "N/A", "N/A", "USD 0.00"]); 81 | } else { 82 | console.log("Rendering Data"); 83 | Object.entries(activeSubscriptions).map(([key, value]) => { 84 | const { name, status, test } = value; 85 | const { amount, currencyCode } = 86 | value.lineItems[0].plan.pricingDetails.price; 87 | rowsData.push([name, status, `${test}`, `${currencyCode} ${amount}`]); 88 | }); 89 | } 90 | setRows(rowsData); 91 | } 92 | useEffect(() => { 93 | getActiveSubscriptions(); 94 | }, []); 95 | 96 | return ( 97 | <> 98 | 99 | 104 | 105 | 106 | ); 107 | }; 108 | 109 | export default BillingAPI; 110 | -------------------------------------------------------------------------------- /client/pages/debug/Data.jsx: -------------------------------------------------------------------------------- 1 | import { 2 | BlockStack, 3 | Button, 4 | Card, 5 | InlineStack, 6 | Layout, 7 | Page, 8 | Text, 9 | } from "@shopify/polaris"; 10 | import { navigate } from "raviger"; 11 | import { useEffect, useState } from "react"; 12 | 13 | const useDataFetcher = (initialState, url, options) => { 14 | const [data, setData] = useState(initialState); 15 | 16 | const fetchData = async () => { 17 | setData("loading..."); 18 | const result = await (await fetch(url, options)).json(); 19 | setData(result.text); 20 | }; 21 | 22 | return [data, fetchData]; 23 | }; 24 | 25 | const DataCard = ({ method, url, data, onRefetch }) => ( 26 | 27 | 28 | 29 | 30 | {method} {url}: {data} 31 | 32 | 33 | 36 | 37 | 38 | 39 | 40 | ); 41 | 42 | const GetData = () => { 43 | const postOptions = { 44 | headers: { 45 | Accept: "application/json", 46 | "Content-Type": "application/json", 47 | }, 48 | method: "POST", 49 | body: JSON.stringify({ text: "Body of POST request" }), 50 | }; 51 | 52 | const [responseData, fetchContent] = useDataFetcher("", "/api/apps"); 53 | const [responseDataPost, fetchContentPost] = useDataFetcher( 54 | "", 55 | "/api/apps", 56 | postOptions 57 | ); 58 | const [responseDataGQL, fetchContentGQL] = useDataFetcher( 59 | "", 60 | "/api/apps/debug/gql" 61 | ); 62 | 63 | useEffect(() => { 64 | fetchContent(); 65 | fetchContentPost(); 66 | fetchContentGQL(); 67 | }, []); 68 | 69 | return ( 70 | navigate("/debug") }} 73 | > 74 | 75 | 81 | 87 | 93 | 94 | 95 | ); 96 | }; 97 | 98 | export default GetData; 99 | -------------------------------------------------------------------------------- /client/pages/debug/Index.jsx: -------------------------------------------------------------------------------- 1 | import { 2 | Button, 3 | Card, 4 | InlineStack, 5 | Layout, 6 | Page, 7 | Text, 8 | BlockStack, 9 | } from "@shopify/polaris"; 10 | import { navigate } from "raviger"; 11 | 12 | const DebugIndex = () => { 13 | return ( 14 | <> 15 | navigate("/") }} 19 | > 20 | 21 | 22 | 23 | 24 | 25 | Scopes 26 | 27 | 28 | Explore what scopes are registered and how to ask for optional 29 | scopes 30 | 31 | 32 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | Data Fetching 49 | 50 | 51 | Send GET, POST and GraphQL queries to your app's backend. 52 | 53 | 54 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | Billing API 71 | 72 | 73 | Subscribe merchant to a plan and explore existing plans. 74 | 75 | 76 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | ); 93 | }; 94 | 95 | export default DebugIndex; 96 | -------------------------------------------------------------------------------- /client/pages/debug/Scopes.jsx: -------------------------------------------------------------------------------- 1 | import { Card, DataTable, Layout, Page, Text } from "@shopify/polaris"; 2 | import { useNavigate } from "raviger"; 3 | import { useEffect, useState } from "react"; 4 | 5 | const OptionalScopes = () => { 6 | const navigate = useNavigate(); 7 | const [rows, setRows] = useState([]); 8 | const [loading, setLoading] = useState(false); 9 | 10 | async function createRows() { 11 | const scopes = await window?.shopify?.scopes?.query(); 12 | if (!scopes) return; 13 | 14 | const rows = [ 15 | [Granted, scopes.granted.join(", ")], 16 | [Required, scopes.required.join(", ")], 17 | [Optional, scopes.optional.join(", ")], 18 | ]; 19 | 20 | setRows(rows); 21 | } 22 | 23 | useEffect(() => { 24 | createRows(); 25 | }, []); 26 | 27 | async function requestScopes() { 28 | setLoading(true); 29 | try { 30 | const response = await window?.shopify?.scopes?.request( 31 | SHOPIFY_API_OPTIONAL_SCOPES?.split(",") //this comes from vite 32 | ); 33 | if (response?.result === "granted-all") { 34 | createRows(); 35 | } else if (response?.result === "declined-all") { 36 | alert("Declined optional scopes"); 37 | } 38 | } catch (e) { 39 | console.dir(e, { depth: null }); 40 | alert( 41 | "Error occured while requesting scopes. Is the scope declared in your env?" 42 | ); 43 | } finally { 44 | setLoading(false); 45 | } 46 | } 47 | return ( 48 | <> 49 | { 55 | requestScopes(); 56 | }, 57 | }} 58 | backAction={{ 59 | onAction: () => { 60 | navigate("/debug"); 61 | }, 62 | }} 63 | > 64 | 65 | 66 | 67 | Type, 72 | Scopes, 73 | ]} 74 | /> 75 | 76 | 77 | 78 | 79 | 80 | ); 81 | }; 82 | 83 | export default OptionalScopes; 84 | -------------------------------------------------------------------------------- /client/providers/AppBridgeProvider.jsx: -------------------------------------------------------------------------------- 1 | const AppBridgeProvider = ({ children }) => { 2 | if (typeof window !== "undefined") { 3 | const shop = window?.shopify?.config?.shop; 4 | 5 | if (!shop) { 6 | return

No Shop Provided

; 7 | } 8 | } 9 | 10 | return <>{children}; 11 | }; 12 | 13 | export default AppBridgeProvider; 14 | -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kinngh/shopify-node-express-mongodb-app/ccfe6926569e1d92d667727e8f9619953d04bbbb/client/public/favicon.ico -------------------------------------------------------------------------------- /client/vite.config.js: -------------------------------------------------------------------------------- 1 | import react from "@vitejs/plugin-react"; 2 | import "dotenv/config"; 3 | import { dirname } from "path"; 4 | import { fileURLToPath } from "url"; 5 | import { defineConfig } from "vite"; 6 | 7 | export default defineConfig({ 8 | define: { 9 | "process.env.SHOPIFY_API_KEY": JSON.stringify(process.env.SHOPIFY_API_KEY), 10 | appOrigin: JSON.stringify( 11 | process.env.SHOPIFY_APP_URL.replace(/https:\/\//, "") 12 | ), 13 | SHOPIFY_API_OPTIONAL_SCOPES: JSON.stringify( 14 | process?.env?.SHOPIFY_API_OPTIONAL_SCOPES 15 | ), 16 | }, 17 | plugins: [react()], 18 | build: { 19 | outDir: "../dist/client/", 20 | }, 21 | root: dirname(fileURLToPath(import.meta.url)), 22 | resolve: { 23 | preserveSymlinks: true, 24 | }, 25 | server: { 26 | allowedHosts: [`${process.env.SHOPIFY_APP_URL.replace(/https:\/\//, "")}`], 27 | cors: false, 28 | }, 29 | }); 30 | -------------------------------------------------------------------------------- /docs/NOTES.md: -------------------------------------------------------------------------------- 1 | # Notes 2 | 3 | ## Index 4 | 5 | - Server Side 6 | - Client Side 7 | - Tips and Tricks 8 | 9 | ## Server 10 | 11 | ### Boilerplate 12 | 13 | - The template uses MongoDB (`mongoose`) as it's database. MongoDB is starter friendly since a lot of tutorials on the internet are based on the MERN stack, it's easier for newbies to understand the structure of the project and working with basic APIs and understand how auth works. 14 | 15 | ### Middlewares 16 | 17 | - The repo comes with all middlewares setup and ready to go to pass auth and ensure things work in accordance to guidelines published by Shopify for app devs. 18 | 19 | ### Routing 20 | 21 | - A basic router is setup at `server/routes/index.js` so you can create your routes and combine them there without having to worry about figuring out where to add in the routers. 22 | 23 | --- 24 | 25 | ## Client 26 | 27 | ### Routing 28 | 29 | - Using a new package `raviger` to add in navigation and routing. I've found it to be much easier to work with, since there's no unnecessary need to use Switches and other boilerpate code. 30 | - Add all your routes to `client/GlobalRoutes.jsx` and then you can use `navigate("/path")` to navigate around, just like Next.js' good ol' `router.push("/path")` 31 | - You can refer to raviger [documentation](https://github.com/Paratron/raviger/blob/master/src-docs/pages/en/README.md), and a [quickstart guide](https://blog.logrocket.com/how-react-hooks-can-replace-react-router/) to understand how it works, passing props and other good stuff. 32 | 33 | ### GraphQL 34 | 35 | - In `client/pages/RecurringSubscriptions.jsx`, the `returnUrl` can also be replaced with `const returnUrl = `https://${shopOrigin}/admin/apps/${process.env.SHOPIFY_API_KEY};`. 36 | - The reason I personally don't prefer this is because Shopify will be moving the admin URL from `store-name.myshopify.com/admin` to `admin.shopify.com` and this specific implementation could break things in the future. Re-running the auth workflow means the redirection would be handled by Shopify and would take us to the right URL directly. 37 | - Also I find this super interesting on how the API Key can be used as an alternative to redirect the user to the app. 38 | 39 | --- 40 | 41 | ## Tips and Tricks 42 | 43 | This section is a collection of tips and tricks I use to speed up my workflow. If you have any, please feel free to add 'em. I use `macOS` so if you're on Windows, this may/may not work. 44 | 45 | ### GitHub Codespaces 46 | 47 | - If you're running this project on GitHub Codespaces and want to run MongoDB on the platform, run the following command in terminal: 48 | `wget -qO - https://www.mongodb.org/static/pgp/server-5.0.asc | sudo apt-key add -;sudo apt-get install gnupg;wget -qO - https://www.mongodb.org/static/pgp/server-5.0.asc | sudo apt-key add -;echo "deb [ arch=amd64,arm64 ] https://repo.mongodb.org/apt/ubuntu bionic/mongodb-org/5.0 multiverse" | sudo tee /etc/apt/sources.list.d/mongodb-org-5.0.list;sudo apt-get update` 49 | - The above code will install MongoDB. Alternatively, you can follow the official installation guide available [here](https://www.mongodb.com/docs/manual/tutorial/install-mongodb-on-ubuntu/) 50 | - MongoDB won't run with `mongod --dbpath mongo/` without root permissions. Run `sudo mongod --dbpath mongo/` instead and it'll work as intended. 51 | 52 | ### NPM Scripts 53 | 54 | - `"env": "cp -R .env.example .env"`: This is to copy the `.env.example` and create a `.env` file (or overwrite it). I usually jump between apps to test functionality or have to switch out variables and this comes really handy in resetting `.env` files. 55 | - `"mongo": "rm -rf mongo/; mkdir mongo; mongod --dbpath mongo/"`: This is to remove the `mongo/` directory if it exists, create a new `mongo` directory and start a MongoDB server in the `mongo/` directory. Comes really handy when you want to start over or test out scenarios like app reinstall, or you just want to start afresh and crash your app to see how it can be improved. You can also break the command into two parts, one to delete and create the `mongo/` directory and the other to start the `mongod` server. 56 | - `"pregit": "clear; npm run pretty; git add ."`: This is really handy to prettify code and adding all files for staging into a `git commit`. Also if you've made any mistakes, `prettier` usually throws an error (if you missed them somewhere) and comes really handy to ensure, yet again, that the code is running and has been prettified. 57 | 58 | ### Terminals 59 | 60 | - `clear; npm run dev`: Dev instance. 61 | - `clear; npm run ngrok`: Tunnel localhost to https server. 62 | - `mongod --dbpath mongo/`: Local mongo server that runs locally in the `mongo/` directory. 63 | - `clear; npm run pretty`: This is usually a spare window that is open depending on what stage of dev I'm in. I like to write the whole code and format for that specific section to be isolated with extra new lines and spaces so it's in focus, then a quick prettify to bring it all together. 64 | 65 | ### UI 66 | 67 | I like to be very clear on how I am building my apps. This is my workflow: 68 | 69 | - Use pen and paper to sketch out the app UI, with [Shopify Polaris Components](https://polaris.shopify.com) open. 70 | - Design in [Figma](https://www.figma.com/community/file/930504625460155381). 71 | - Use the [Shopify Polaris Playground](https://github.com/kinngh/shopify-polaris-playground) repo I built to see how the UI feels like. 72 | - Drop in the UI (without the `App.jsx` file) directly into my repo and then start writing my backend / frontend code. 73 | 74 | About using a design tool, don't feel forced to use a tool like Figma, Adobe XD, etc. Your tool's job is to give you freedom and sometimes it's not software, but a white board or pen and paper. Do what makes you feel like you have the freedom to build and you can directly skip making a design set in a tool and code it in with your sketches open. 75 | 76 | ### GraphQL 77 | 78 | - Shopify.dev has a [Graphiql interface](https://shopify.dev/graphiql/admin-graphiql) available where you can build your queries and mutations. I personally like to build my queries and mutations here because of how easy it is to test things out and the explore section makes it really easy to build the queries. 79 | - If you want to access queries or mutations, just type in `query` or `mutation` and click on Explore, and it'll open up the latest version of end points available. Build your query, prettify and paste in your repo. Also make sure you have the right scopes added in or you're going to run into errors that you may / may not spend an entire day figuring out. 80 | -------------------------------------------------------------------------------- /docs/SETUP.md: -------------------------------------------------------------------------------- 1 | # Setup 2 | 3 | This is an in-depth guide on using this repo. This goes over getting the base repo up and running, to understand how to add your own customizations server side like registering webhooks, routes, etc, refer to [Notes](/docs/NOTES.md). 4 | 5 | `Windows` users, run `npm install -g win-node-env` before running the repo since `NODE_ENV` isn't recognized in Windows. Alternatively, you can replace it with `cross-env` and update `package.json` accordingly. 6 | 7 | - [ ] Run `npm run g:install` to install Shopify's global dependencies if you haven't already. 8 | - [ ] Run `npm i --force` to install dependencies. 9 | 10 | - Substantial efforts have gone into ensuring we're using the latest package versions, and some incompatibility issues always pop up while installing. There are no negative effects on the functionality just yet, but if you find anything please open an issue. 11 | 12 | - Do not delete `shopify.app.toml` file since that's required by Shopify CLI 3.0 to function properly, even if the file is empty. 13 | 14 | - [ ] Create a new app (Public or Custom) from your [Shopify Partner Dashboard](https://partners.shopify.com). 15 | 16 | - The App URL will be generated later in the setup. Add `https://localhost` for now. 17 | 18 | - [ ] Build your `.env` file based on `.env.example` 19 | 20 | - `SHOPIFY_API_KEY`: App API key. 21 | - `SHOPIFY_API_SECRET`: App secret. 22 | - `SHOPIFY_API_SCOPES`: Scopes required by your Shopify app. A list of access scopes can be found [here](https://shopify.dev/api/usage/access-scopes) 23 | - `SHOPIFY_API_OPTIONAL_SCOPES`: Optional scopes required by your Shopify app. You can see it in action at `/debug/scopes`. 24 | - `SHOPIFY_APP_URL`: URL generated from Ngrok/Cloudflare. 25 | - `SHOPIFY_API_VERSION`: Pre-filled to the latest version. All the calls in the repo are based off this API version so if you're downgrading please refer to the official docs instead. The repo is always kept up to date with the newest practices so you can rely on the basic repo to almost always work without depriciation errors popping up. 26 | - `MONGO_URL`: Mongo connection URL. If you're using a locally hosted version, you can leave it blank or use `mongodb://127.0.0.1:27017/app-name-here` 27 | - `ENCRYPTION_STRING`: String to use for Cryption for encrypting sessions token. Add a random salt (or a random string of letters and numbers) and save it. If you loose the string you cannot decrypt your sessions and must be kept safely. 28 | - `NPM_CONFIG_FORCE`: Set to `true` so if you deploy on spaces like Heroku, it runs `npm install --force` instead of `npm install`. 29 | - 30 | - `VITE_SHOPIFY_API_KEY`: Used by Vite, this is the app API key, should be same as `SHOPIFY_API_KEY`. 31 | - 32 | - `APP_NAME`: Name of your app, as you've entered in Partner Dashboard. 33 | - `APP_HANDLE`: The URL handle of your app. 34 | - 35 | - `APP_PROXY_PREFIX`: The prefix for your App Proxy's path, can be one of these: 36 | - apps 37 | - a 38 | - community 39 | - tools 40 | - `APP_PROXY_SUBPATH`: Subpath for your app proxy. 41 | - Leave `APP_PROXY_PREFIX` or `APP_PROXY_SUBPATH` blank and no App Proxy entries are created. 42 | - 43 | - `POS_EMBEDDED`: Boolean. If your app is embedded in Shopify Point of Sale. 44 | 45 | - [ ] NPM Scripts 46 | 47 | - `dev`: Run in dev mode. 48 | - `build`: Use Vite to build React into `dist/client`. If you don't run build, you cannot serve anything in dev / production modes. 49 | - `start`: Run in production mode. Please run `npm run build` before to compile client side. 50 | - 51 | - `update`: Depends on `npm-check-updates` to force update packages to the latest available version. Can potentially break things. 52 | - `pretty`: Run prettier across the entire project. I personally like my code to be readable and using prettier CLI makes things easier. Refer to `.prettierrc` for configuration and `.prettierignore` to ignore files and folders. 53 | - 54 | - `ngrok:auth`: Add in your auth token from [Ngrok](https://ngrok.com) to use the service. 55 | - `ngrok`: Ngrok is used to expose specific ports of your machine to the internet and serve over https. Running `npm run ngrok` auto generates a URL for you. The URL that's generated here goes in `SHOPIFY_APP_URL` and in the URL section of your app in Partner Dashboard. 56 | - `cloudflare`: Starts cloudflare tunnel on port 8081 (make sure you have `cloudflared` installed). 57 | - 58 | - `g:install`: Required global installs for buildling Shopify apps. 59 | - `shopify`: Run `shopify` commands 60 | - `update:config`: [Managed Installation] Use the Shopify CLI to update your configuration. Auto writes your `toml` file to root and `extension/` for syncing. 61 | - `update:url`: [OAuth Installation] Use `@shopify/cli-kit` to update URLs to your Shopify partner dashboard. Requires a proper setup of `.env` file. 62 | - 63 | - `preserve`: For Vite. 64 | 65 | - [ ] Setup Partner Dashboard 66 | 67 | - Run `npm run cloudflare` or `npm run ngrok` to generate your subdomain. Copy the `https://` domain and add it in `SHOPIFY_APP_URL` and in your `.env` file. 68 | - Run `npm run update:config` to generate `shopify.app.toml` files and upload your config to Shopify. 69 | - ABSOLUTELY DO NOT MODIFY YOUR TOML FILES BY HAND. USE YOUR ENV INSTEAD. 70 | - A common _gotcha_ is ensuring you are using the same URL in your `.env` and App Setup sections and any discrepancy will result in "URI not whitelisted" issue. 71 | - GPDR handlers are available at `server/controllers/gdpr.js` and the URLs to register are: 72 | - Customers Data Request: `https:///api/gdpr/customers_data_request` 73 | - Customers Redact: `https:///api/gdpr/customers_redact` 74 | - Shop Redact: `https:///api/gdpr/shop_redact` 75 | - App Proxy routes are setup to allow accessing data from your app directly from the store. An example proxy route has been setup and is available at `server/index.js` at `//MARK:- App Proxy routes` and the routes are available in `server/routes/app_proxy/`. First you need to setup your base urls. Here's how to get it working: 76 | 77 | - Subpath Prefix: `apps` 78 | - Subpath: `express-proxy` 79 | - Proxy URL: `https:///api/proxy_route` 80 | 81 | - So when a merchant visits `https://shop-url.com/apps/express-proxy/`, the response to that request will come from `https:///proxy_route`. A middleware has already been setup to check signatures so you don't have to worry about authenticating proxy calls, and is available at `server/middleware/proxyVerification.js`. 82 | - Subsequently, any child requests will be mapped the same way. A call to `https://shop-url.com/apps/express-proxy/json` will be routed to `https:///api/proxy_route/json`. 83 | - To confirm if you've setup app proxy properly, head over to `https://shop-url.myshopify.com/apps/express-proxy/json` to confirm if you get a JSON being returned with the configuration set above^ 84 | - A common _gotcha_ is if you're creating multiple apps that all use the same subpath (`express-proxy` in this case), all susbequent installs will throw a `404` error because Shopify serializes routes based on installation. To avoid this, please change the subpath to something that's unique to your app. I prefer using the format `<>-proxy` 85 | 86 | - [ ] Running App 87 | 88 | - Install the app by heading over to `storename.myshopify.com/admin/oauth/install?client_id=SHOPIFY_API_KEY`. 89 | - I prefer running a local `mongod` instance to save on time and ease of setup. Create a new folder in your project called `mongo` (it's added in `.gitignore` so you can git freely) and in a terminal window run `mongod --dbpath mongo/` to start a mongo instance in that folder. 90 | - In your second terminal window, run `npm run cloudflare` or `npm run ngrok` to tunnel your localhost to the web via HTTPS. 91 | - In your third terminal window (preferrably in your IDE), `npm run dev` or `npm run start` depending on how you want to test your app. Make sure to add the generated URL to `SHOPIFY_APP_URL` in `.env` file. 92 | 93 | - [ ] Creating Extensions 94 | - See [DOCS](./migrations/oauth-to-managed-installation.md) point 8. 95 | -------------------------------------------------------------------------------- /docs/SNIPPETS.md: -------------------------------------------------------------------------------- 1 | # Snippets 2 | 3 | A collection of snippets to quickly create further boilerplate code with relevant functions and checks. 4 | 5 | I selected these snippets from my regular use and code that I was writing over and over again. Instead of again including a `__templates/` directory that was causing a lot of confusion, it made more sense to add this as a workspace snippet library instead. This also avoided the issue of creating and maintaining extensions. 6 | 7 | If you don't want the snippets, delete the [snippets file](../.vscode/snippets.code-snippets) 8 | 9 | ## Snippets 10 | 11 | | Snippet | Description | 12 | | ------------------------ | ------------------------------------------------------------------------------- | 13 | | `sfc` | Create an arrow function component | 14 | | `createNewPage` | Create a new Polaris page with a LegacyCard | 15 | | `createroute` | Create a new route. Works with regular, app proxy and checkout extension routes | 16 | | `createwebhook` | Create a new webhook function | 17 | | `createClientGql` | Create a new GraphQL Client with online tokens | 18 | | `createOfflineClientGql` | Create a new GraphQL Client with offline tokens | 19 | | `createpost` | Create a new POST request | 20 | | `createget` | Create a new GET request | 21 | -------------------------------------------------------------------------------- /docs/migrations/graphql-webhooks-to-managed-webhooks.md: -------------------------------------------------------------------------------- 1 | # Migrating to Managed Webhooks 2 | 3 | Managed Webhooks are configured via your TOML file. If you have been using the repo before, the process is almost the same. 4 | 5 | - Head over to `utils/shopify.js` and add in your webhook topics. 6 | - There's autocomplete available for topics 7 | - All webhook URLs start with `/api/webhooks` 8 | - If you're using AWS, GCP or any other external server to process webhooks, add in the full URL and it'll handle accordingly 9 | - If this server is handling webhooks, the `/server/webhooks/_index.js` will be updated based on `/utils/shopify.js` file 10 | - Do not write to `/server/webhooks/_index.js` because it'll be overwritten. 11 | - Run `npm run update:config` as usual 12 | 13 | ## Migration 14 | 15 | - Moving to managed webhooks doesn't remove GraphQL webhooks that your app would have registered earlier and these need to be removed manually. 16 | 17 | ## Read More 18 | 19 | - https://shopify.dev/docs/apps/build/webhooks/customize 20 | -------------------------------------------------------------------------------- /docs/migrations/oauth-to-managed-installation.md: -------------------------------------------------------------------------------- 1 | # OAuth to Managed Installation 2 | 3 | Shopify introduced `Shopify Managed Installation` to get rid of screen flickering during auth, unnecessary auth redirects while fetching online sessions and other misc issues. To oversimplify, you pass the access tokens to Shopify and get Session tokens in return like a regular fetch and save it in your database. Here's a rundown of what's changed: 4 | 5 | 1. Changes in Auth 6 | 7 | The older way of doing auth is still supported from Shopify but for embedded apps in this repo, it's gone and so are the files. I've completely removed the older strait to run auth, which means the `auth` middleware and it's routes are completely gone. 8 | 9 | 2. Updates to `isShopAvailable` function 10 | 11 | `isShopAvailable` has been renamed to `isInitialLoad`. The new strait means on the first load we get `id_token` as a query param that is exchanged for online and offline session tokens. `isInitialLoad` checks if these params exist, exchanges them for online and offline tokens and saves them in the db. 12 | 13 | A new check also happens here, `isFreshInstall`. Since the database structure is kept the same to ensure smooth transition to the new auth, we can now check if the install was a fresh one. If the store doesn't exist in the `store` model, it's a new install, but if it does have a `Bool` value, that means it's either already installed or is a reinstall. While I've merged these in an if condition, you can break them apart and run your own checks if required. 14 | 15 | ```javascript 16 | if (!isFreshInstall || isFreshInstall?.isActive === false) { 17 | // !isFreshInstall -> New Install 18 | // isFreshInstall?.isActive === false -> Reinstall 19 | await freshInstall({ shop: onlineSession.shop }); 20 | } 21 | ``` 22 | 23 | This is now followed up with a `props` return since `getServerSideProps` has to return it. 24 | 25 | 3. Changes to `verifyRequest` and `ExitFrame` 26 | 27 | The `verifyRequest()` middleware now works completely differently. First we check for `authorization` headers in each `fetch()` since App Bridge CDN automatically adds headers to each `fetch`. Then a JWT validation is run to ensure the headers are valid, followed by getting the session id and rading from the database, check for expiry and fetch new tokens if the online tokens have expired. Then pass the session to use in subsequent routes as `req.user_session` and the middleware is done. 28 | 29 | A great thing about this is `ExitFrame` doesn't exist anymore. If the tokens are invalid, we throw a `401` and if the tokens are expired, we fetch them and move on to the next set. 30 | 31 | 4. Quick auth URL 32 | 33 | The quick auth URL has gotten an update. We've moved from `https://appurl.com/api/auth?shop=storename.myshopify.com` to `https://storename.myshopify.com/admin/oauth/install?client_id=SHOPIFY_API_KEY`, which now takes the merchant to the install screen. 34 | 35 | 5. Depricating `useFetch()` hook 36 | 37 | The idea of `useFetch()` was to redirect towards `ExitFrame` if the tokens had expired or not found - this is not required anymore. All vanilla `fetch` requests work since AppBridge CDN adds in authorization headers in the background. 38 | 39 | 6. Updates to `package.json` scripts and dev mode 40 | 41 | - No need to swap between multiple ports for Dev and Production - it's all served from the same port 42 | - `npm run update:url` has been deprecated, and is now `npm run update:config`. This creates all your `toml` files exactly where you need them, so you're still managing all your configurations from a single file, which is your `.env`, instead of multiple configuration files. 43 | 44 | 7. Thoughts 45 | 46 | Managed installation is great. No flickering, no running through ExitFrame, it's 10/10 all around. The only problem is now you don't get a hit when someone comes over to the permissions screen and are only made aware of the store when the permissions are approved. The new `tomlWriter` was built so that you are still only relying on your `env` and that's writing your `shopify.app.toml` file to root (and `extension/` folder). It took a second to wrap my head around but once you get the hang of it, it's great. 47 | 48 | 8. Extensions 49 | a. To create extensions, make a new folder called `app` and transfer all files except for 50 | 51 | - `.github` 52 | - `docs/` 53 | - `LICENSE` 54 | 55 | b. Create a new folder called `extension` and run `npm init --y` inside of it to ensure you have a package.json in there. 56 | 57 | c. Go into `app/` and run `npm run update:config` so the `_developer/tomlWriter` can create all your `toml` files. 58 | 59 | d. At this point, your folder structure should look something like this: 60 | 61 | ``` 62 | .github/ 63 | docs/ 64 | package.json 65 | 66 | app/ 67 | app/server/... 68 | app/client... 69 | app/(other folders ) 70 | 71 | extension/ 72 | extension/package.json 73 | ``` 74 | 75 | e. Get back into `extension/` and run `shopify app generate extension` to start creating your extensions. 76 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "shopify-express-boilerplate", 3 | "version": "2025.05.06", 4 | "description": "Shopify Boilerplate Code written in React, Node, Express, MongoDB and GraphQL", 5 | "type": "module", 6 | "author": { 7 | "name": "Harshdeep Singh Hura", 8 | "url": "https://harshdeephura.com" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/kinngh/shopify-node-express-mongodb-app.git" 13 | }, 14 | "scripts": { 15 | "dev": "NODE_ENV=dev nodemon server/index.js --ignore client/ --ignore dist/ --ignore server/index.js", 16 | "build": "vite build --config=./client/vite.config.js", 17 | "start": "NODE_ENV=prod node server/index.js", 18 | "-----> utils <-----": "", 19 | "update": "ncu -u", 20 | "pretty": "prettier --write .", 21 | "-----> Tunnel <-----": "", 22 | "ngrok:auth": "ngrok authtoken ", 23 | "ngrok": "ngrok http 8081", 24 | "cloudflare": "cloudflared tunnel --url localhost:8081", 25 | "-----> Shopify <-----": "", 26 | "g:install": "npm i -g @shopify/cli@latest", 27 | "shopify": "shopify", 28 | "update:config": "node _developer/tomlWriter.js; npm run pretty; shopify app deploy;", 29 | "update:url": "node _developer/updateDashboard.js", 30 | "-----> Reserved Scripts <-----": "", 31 | "preserve": "npm run build" 32 | }, 33 | "dependencies": { 34 | "@shopify/polaris": "^13.9.5", 35 | "@shopify/shopify-api": "^11.13.0", 36 | "compression": "^1.8.0", 37 | "cors": "^2.8.5", 38 | "cryptr": "^6.3.0", 39 | "dotenv": "^16.5.0", 40 | "express": "^5.1.0", 41 | "mongoose": "^8.15.1", 42 | "raviger": "^4.2.1", 43 | "react": "^18.3.1", 44 | "react-dom": "^18.3.1", 45 | "serve-static": "^2.2.0" 46 | }, 47 | "devDependencies": { 48 | "@iarna/toml": "^2.2.5", 49 | "@shopify/cli-kit": "^3.80.7", 50 | "@vitejs/plugin-react": "^4.5.2", 51 | "concurrently": "^9.1.2", 52 | "ngrok": "^5.0.0-beta.2", 53 | "nodemon": "^3.1.10", 54 | "npm-check-updates": "^18.0.1", 55 | "prettier": "^3.5.3", 56 | "vite": "^6.3.5" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /server/controllers/gdpr.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * CUSTOMER_DATA_REQUEST 4 | * 5 | */ 6 | 7 | const customerDataRequest = async (topic, shop, webhookRequestBody) => { 8 | // Payload 9 | // { 10 | // "shop_id": 123456, 11 | // "shop_domain": "store.myshopify.com", 12 | // "orders_requested": [ 13 | // 123456, 14 | // 123456, 15 | // 123456, 16 | // ], 17 | // "customer": { 18 | // "id": 123456, 19 | // "email": "email@email.com", 20 | // "phone": "123-123-1231" 21 | // }, 22 | // "data_request": { 23 | // "id": 1111 24 | // } 25 | // } 26 | try { 27 | console.log(`Handle ${topic} for ${shop}`); 28 | console.log(webhookRequestBody); 29 | return { success: true }; 30 | } catch (e) { 31 | console.error(e); 32 | return { success: false }; 33 | } 34 | }; 35 | 36 | /** 37 | * 38 | * CUSTOMER_REDACT 39 | * 40 | */ 41 | 42 | const customerRedact = async (topic, shop, webhookRequestBody) => { 43 | // Payload 44 | // { 45 | // "shop_id": 123456, 46 | // "shop_domain": "store.myshopify.com", 47 | // "customer": { 48 | // "id": 123456, 49 | // "email": "email@email.com", 50 | // "phone": "123-123-1234" 51 | // }, 52 | // "orders_to_redact": [ 53 | // 123456, 54 | // 123456, 55 | // 123456 56 | // ] 57 | // } 58 | try { 59 | console.log(`Handle ${topic} for ${shop}`); 60 | console.log(webhookRequestBody); 61 | return { success: true }; 62 | } catch (e) { 63 | console.error(e); 64 | return { success: false }; 65 | } 66 | }; 67 | 68 | /** 69 | * 70 | * SHOP_REDACT 71 | * 72 | */ 73 | 74 | const shopRedact = async (topic, shop, webhookRequestBody) => { 75 | // Payload 76 | // { 77 | // "shop_id": 123456, 78 | // "shop_domain": "store.myshopify.com" 79 | // } 80 | try { 81 | console.log(`Handle ${topic} for ${shop}`); 82 | console.log(webhookRequestBody); 83 | return { success: true }; 84 | } catch (e) { 85 | console.error(e); 86 | return { success: false }; 87 | } 88 | }; 89 | 90 | export { customerDataRequest, customerRedact, shopRedact }; 91 | -------------------------------------------------------------------------------- /server/index.js: -------------------------------------------------------------------------------- 1 | import "@shopify/shopify-api/adapters/node"; 2 | import "dotenv/config"; 3 | import cors from "cors"; 4 | import Express from "express"; 5 | import fs from "fs"; 6 | import mongoose from "mongoose"; 7 | import path, { resolve } from "path"; 8 | import { createServer as createViteServer } from "vite"; 9 | import sessionHandler from "../utils/sessionHandler.js"; 10 | import setupCheck from "../utils/setupCheck.js"; 11 | import shopify from "../utils/shopify.js"; 12 | import { 13 | customerDataRequest, 14 | customerRedact, 15 | shopRedact, 16 | } from "./controllers/gdpr.js"; 17 | import csp from "./middleware/csp.js"; 18 | import isInitialLoad from "./middleware/isInitialLoad.js"; 19 | import verifyCheckout from "./middleware/verifyCheckout.js"; 20 | import verifyHmac from "./middleware/verifyHmac.js"; 21 | import verifyProxy from "./middleware/verifyProxy.js"; 22 | import verifyRequest from "./middleware/verifyRequest.js"; 23 | import proxyRouter from "./routes/app_proxy/index.js"; 24 | import checkoutRoutes from "./routes/checkout/index.js"; 25 | import userRoutes from "./routes/index.js"; 26 | import webhookHandler from "./webhooks/_index.js"; 27 | 28 | setupCheck(); // Run a check to ensure everything is setup properly 29 | 30 | const PORT = parseInt(process.env.PORT, 10) || 8081; 31 | const isDev = process.env.NODE_ENV === "dev"; 32 | 33 | // MongoDB Connection 34 | const mongoUrl = 35 | process.env.MONGO_URL || "mongodb://127.0.0.1:27017/shopify-express-app"; 36 | 37 | mongoose.connect(mongoUrl); 38 | 39 | const createServer = async (root = process.cwd()) => { 40 | const app = Express(); 41 | app.disable("x-powered-by"); 42 | 43 | // Incoming webhook requests 44 | app.post( 45 | "/api/webhooks/:webhookTopic*", 46 | Express.text({ type: "*/*" }), 47 | webhookHandler 48 | ); 49 | 50 | app.use(Express.json()); 51 | 52 | app.post("/api/graphql", verifyRequest, async (req, res) => { 53 | try { 54 | const sessionId = await shopify.session.getCurrentId({ 55 | isOnline: true, 56 | rawRequest: req, 57 | rawResponse: res, 58 | }); 59 | const session = await sessionHandler.loadSession(sessionId); 60 | const response = await shopify.clients.graphqlProxy({ 61 | session, 62 | rawBody: req.body, 63 | }); 64 | res.status(200).send(response.body); 65 | } catch (e) { 66 | console.error(`---> An error occured at GraphQL Proxy`, e); 67 | res.status(403).send(e); 68 | } 69 | }); 70 | 71 | app.use(csp); 72 | app.use(isInitialLoad); 73 | //Routes to make server calls 74 | app.use("/api/apps", verifyRequest, userRoutes); //Verify user route requests 75 | app.use("/api/proxy_route", verifyProxy, proxyRouter); //MARK:- App Proxy routes 76 | app.use( 77 | "/api/checkout", 78 | cors({ 79 | origin: "https://extensions.shopifycdn.com", 80 | methods: ["GET", "POST", "OPTIONS"], 81 | allowedHeaders: ["Authorization", "Content-Type"], 82 | optionsSuccessStatus: 200, 83 | }), 84 | verifyCheckout, 85 | checkoutRoutes 86 | ); 87 | 88 | app.post("/api/gdpr/:topic", verifyHmac, async (req, res) => { 89 | const { body } = req; 90 | const { topic } = req.params; 91 | const shop = req.body.shop_domain; 92 | 93 | console.warn(`--> GDPR request for ${shop} / ${topic} recieved.`); 94 | 95 | let response; 96 | switch (topic) { 97 | case "customers_data_request": 98 | response = await customerDataRequest(topic, shop, body); 99 | break; 100 | case "customers_redact": 101 | response = await customerRedact(topic, shop, body); 102 | break; 103 | case "shop_redact": 104 | response = await shopRedact(topic, shop, body); 105 | break; 106 | default: 107 | console.error( 108 | "--> Congratulations on breaking the GDPR route! Here's the topic that broke it: ", 109 | topic 110 | ); 111 | response = "broken"; 112 | break; 113 | } 114 | 115 | if (response.success) { 116 | res.status(200).send(); 117 | } else { 118 | res.status(403).send("An error occured"); 119 | } 120 | }); 121 | 122 | if (isDev) { 123 | const vite = await createViteServer({ 124 | root: path.resolve(process.cwd(), "client"), 125 | server: { 126 | middlewareMode: true, 127 | hmr: { 128 | server: app.listen(PORT, () => { 129 | console.log(`Dev server running on localhost:${PORT}`); 130 | }), 131 | }, 132 | }, 133 | appType: "spa", 134 | }); 135 | app.use(vite.middlewares); 136 | app.use("*", async (req, res) => { 137 | const url = req.originalUrl; 138 | let template = fs.readFileSync( 139 | path.resolve(process.cwd(), "client", "index.html"), 140 | "utf-8" 141 | ); 142 | template = await vite.transformIndexHtml(url, template); 143 | res.status(200).set({ "Content-Type": "text/html" }).end(template); 144 | }); 145 | } else { 146 | const compression = await import("compression").then( 147 | ({ default: fn }) => fn 148 | ); 149 | const serveStatic = await import("serve-static").then( 150 | ({ default: fn }) => fn 151 | ); 152 | 153 | app.use(compression()); 154 | app.use(serveStatic(resolve("dist/client"))); 155 | app.use("/*", (req, res, next) => { 156 | res 157 | .status(200) 158 | .set("Content-Type", "text/html") 159 | .send(fs.readFileSync(`${root}/dist/client/index.html`)); 160 | }); 161 | } 162 | 163 | return { app }; 164 | }; 165 | 166 | if (isDev) { 167 | createServer(); 168 | } else { 169 | createServer().then(({ app }) => { 170 | app.listen(PORT, () => { 171 | console.log(`--> Running on ${PORT}`); 172 | }); 173 | }); 174 | } 175 | -------------------------------------------------------------------------------- /server/middleware/csp.js: -------------------------------------------------------------------------------- 1 | import shopify from "../../utils/shopify.js"; 2 | 3 | /** 4 | * @param {import('express').Request} req - Express request object 5 | * @param {import('express').Response} res - Express response object 6 | * @param {import('express').NextFunction} next - Express next middleware function 7 | */ 8 | const csp = (req, res, next) => { 9 | const shop = req.query.shop || "*.myshopify.com"; 10 | if (shopify.config.isEmbeddedApp && shop) { 11 | res.setHeader( 12 | "Content-Security-Policy", 13 | `frame-ancestors https://${shop} https://admin.shopify.com;` 14 | ); 15 | } else { 16 | res.setHeader("Content-Security-Policy", "frame-ancestors 'none';"); 17 | } 18 | 19 | next(); 20 | }; 21 | 22 | export default csp; 23 | -------------------------------------------------------------------------------- /server/middleware/isInitialLoad.js: -------------------------------------------------------------------------------- 1 | import { RequestedTokenType } from "@shopify/shopify-api"; 2 | import StoreModel from "../../utils/models/StoreModel.js"; 3 | import sessionHandler from "../../utils/sessionHandler.js"; 4 | import shopify from "../../utils/shopify.js"; 5 | import freshInstall from "../../utils/freshInstall.js"; 6 | 7 | /** 8 | * @param {import('express').Request} req - Express request object 9 | * @param {import('express').Response} res - Express response object 10 | * @param {import('express').NextFunction} next - Express next middleware function 11 | */ 12 | const isInitialLoad = async (req, res, next) => { 13 | try { 14 | const shop = req.query.shop; 15 | const idToken = req.query.id_token; 16 | 17 | if (shop && idToken) { 18 | const { session: offlineSession } = await shopify.auth.tokenExchange({ 19 | sessionToken: idToken, 20 | shop, 21 | requestedTokenType: RequestedTokenType.OfflineAccessToken, 22 | }); 23 | const { session: onlineSession } = await shopify.auth.tokenExchange({ 24 | sessionToken: idToken, 25 | shop, 26 | requestedTokenType: RequestedTokenType.OnlineAccessToken, 27 | }); 28 | 29 | await sessionHandler.storeSession(offlineSession); 30 | await sessionHandler.storeSession(onlineSession); 31 | 32 | const webhookRegistrar = await shopify.webhooks.register({ 33 | session: offlineSession, 34 | }); 35 | 36 | const isFreshInstall = await StoreModel.findOne({ 37 | shop: onlineSession.shop, 38 | }); 39 | 40 | if (!isFreshInstall || isFreshInstall?.isActive === false) { 41 | // !isFreshInstall -> New Install 42 | // isFreshInstall?.isActive === false -> Reinstall 43 | await freshInstall({ shop: onlineSession.shop }); 44 | } 45 | 46 | console.dir(webhookRegistrar, { depth: null }); 47 | } 48 | next(); 49 | } catch (e) { 50 | console.error(`---> An error occured in isInitialLoad`, e); 51 | return res.status(403).send({ error: true }); 52 | } 53 | }; 54 | 55 | export default isInitialLoad; 56 | -------------------------------------------------------------------------------- /server/middleware/verifyCheckout.js: -------------------------------------------------------------------------------- 1 | import shopify from "../../utils/shopify.js"; 2 | import validateJWT from "../../utils/validateJWT.js"; 3 | 4 | /** 5 | * @param {import('express').Request} req - Express request object 6 | * @param {import('express').Response} res - Express response object 7 | * @param {import('express').NextFunction} next - Express next middleware function 8 | */ 9 | const verifyCheckout = async (req, res, next) => { 10 | try { 11 | if (req.method === "OPTIONS") { 12 | res.status(200).end(); 13 | return; 14 | } 15 | 16 | const authHeader = req.headers["authorization"]; 17 | if (!authHeader) { 18 | throw Error("No authorization header found"); 19 | } 20 | 21 | const payload = validateJWT(authHeader.split(" ")[1]); 22 | 23 | let shop = shopify.utils.sanitizeShop(payload.dest.replace("https://", "")); 24 | 25 | if (!shop) { 26 | throw Error("No shop found, not a valid request"); 27 | } 28 | 29 | res.locals.user_shop = shop; 30 | 31 | next(); 32 | } catch (e) { 33 | console.error( 34 | `---> An error happened at verifyCheckout middleware: ${e.message}` 35 | ); 36 | return res.status(401).send({ error: "Unauthorized call" }); 37 | } 38 | }; 39 | 40 | export default verifyCheckout; 41 | -------------------------------------------------------------------------------- /server/middleware/verifyHmac.js: -------------------------------------------------------------------------------- 1 | import crypto from "crypto"; 2 | import shopify from "../../utils/shopify.js"; 3 | 4 | /** 5 | * @param {import('express').Request} req - Express request object 6 | * @param {import('express').Response} res - Express response object 7 | * @param {import('express').NextFunction} next - Express next middleware function 8 | */ 9 | const verifyHmac = (req, res, next) => { 10 | try { 11 | const generateHash = crypto 12 | .createHmac("SHA256", process.env.SHOPIFY_API_SECRET) 13 | .update(JSON.stringify(req.body), "utf8") 14 | .digest("base64"); 15 | const hmac = req.headers["x-shopify-hmac-sha256"]; 16 | 17 | if (shopify.auth.safeCompare(generateHash, hmac)) { 18 | next(); 19 | } else { 20 | return res.status(401).send(); 21 | } 22 | } catch (e) { 23 | console.log(e); 24 | return res.status(401).send(); 25 | } 26 | }; 27 | 28 | export default verifyHmac; 29 | -------------------------------------------------------------------------------- /server/middleware/verifyProxy.js: -------------------------------------------------------------------------------- 1 | import crypto from "crypto"; 2 | 3 | /** 4 | * @param {import('express').Request} req - Express request object 5 | * @param {import('express').Response} res - Express response object 6 | * @param {import('express').NextFunction} next - Express next middleware function 7 | */ 8 | const verifyProxy = (req, res, next) => { 9 | const { signature } = req.query; 10 | 11 | const queryURI = req._parsedUrl.query 12 | .replace("/?", "") 13 | .replace(/&signature=[^&]*/, "") 14 | .split("&") 15 | .map((x) => decodeURIComponent(x)) 16 | .sort() 17 | .join(""); 18 | 19 | const calculatedSignature = crypto 20 | .createHmac("sha256", process.env.SHOPIFY_API_SECRET) 21 | .update(queryURI, "utf-8") 22 | .digest("hex"); 23 | 24 | if (calculatedSignature === signature) { 25 | res.locals.user_shop = req.query.shop; 26 | next(); 27 | } else { 28 | return res.send(401); 29 | } 30 | }; 31 | 32 | export default verifyProxy; 33 | -------------------------------------------------------------------------------- /server/middleware/verifyRequest.js: -------------------------------------------------------------------------------- 1 | import sessionHandler from "../../utils/sessionHandler.js"; 2 | import shopify from "../../utils/shopify.js"; 3 | import validateJWT from "../../utils/validateJWT.js"; 4 | import { RequestedTokenType, Session } from "@shopify/shopify-api"; 5 | 6 | /** 7 | * @param {import('express').Request} req - Express request object 8 | * @param {import('express').Response} res - Express response object 9 | * @param {import('express').NextFunction} next - Express next middleware function 10 | */ 11 | const verifyRequest = async (req, res, next) => { 12 | try { 13 | const authHeader = req.headers["authorization"]; 14 | if (!authHeader) { 15 | throw Error("No authorization header found"); 16 | } 17 | 18 | const payload = validateJWT(authHeader.split(" ")[1]); 19 | 20 | let shop = shopify.utils.sanitizeShop(payload.dest.replace("https://", "")); 21 | 22 | if (!shop) { 23 | throw Error("No shop found, not a valid request"); 24 | } 25 | 26 | const sessionId = await shopify.session.getCurrentId({ 27 | isOnline: true, 28 | rawRequest: req, 29 | rawResponse: res, 30 | }); 31 | 32 | let session = await sessionHandler.loadSession(sessionId); 33 | if (!session) { 34 | session = await getSession({ shop, authHeader }); 35 | } 36 | 37 | if ( 38 | new Date(session?.expires) > new Date() && 39 | shopify.config.scopes.equals(session?.scope) 40 | ) { 41 | } else { 42 | session = await getSession({ shop, authHeader }); 43 | } 44 | res.locals.user_session = session; 45 | next(); 46 | } catch (e) { 47 | console.error( 48 | `---> An error happened at verifyRequest middleware: ${e.message}` 49 | ); 50 | return res.status(401).send({ error: "Unauthorized call" }); 51 | } 52 | }; 53 | 54 | export default verifyRequest; 55 | 56 | /** 57 | * Retrieves and stores session information based on the provided authentication header and offline flag. 58 | * If the `offline` flag is true, it will also attempt to exchange the token for an offline session token. 59 | * Errors during the process are logged to the console. 60 | * 61 | * @async 62 | * @function getSession 63 | * @param {Object} params - The function parameters. 64 | * @param {string} params.shop - The xxx.myshopify.com url of the requesting store. 65 | * @param {string} params.authHeader - The authorization header containing the session token. 66 | * @returns {Promise} The online session object 67 | */ 68 | 69 | async function getSession({ shop, authHeader }) { 70 | try { 71 | const sessionToken = authHeader.split(" ")[1]; 72 | 73 | const { session: onlineSession } = await shopify.auth.tokenExchange({ 74 | sessionToken, 75 | shop, 76 | requestedTokenType: RequestedTokenType.OnlineAccessToken, 77 | }); 78 | 79 | await sessionHandler.storeSession(onlineSession); 80 | 81 | const { session: offlineSession } = await shopify.auth.tokenExchange({ 82 | sessionToken, 83 | shop, 84 | requestedTokenType: RequestedTokenType.OfflineAccessToken, 85 | }); 86 | 87 | await sessionHandler.storeSession(offlineSession); 88 | 89 | return new Session(onlineSession); 90 | } catch (e) { 91 | console.error( 92 | `---> Error happened while pulling session from Shopify: ${e.message}` 93 | ); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /server/routes/app_proxy/index.js: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import clientProvider from "../../../utils/clientProvider.js"; 3 | const proxyRouter = Router(); 4 | 5 | /** 6 | * @param {import('express').Request} req - Express request object 7 | * @param {import('express').Response} res - Express response object 8 | */ 9 | proxyRouter.get("/json", async (req, res) => { 10 | try { 11 | const { client } = await clientProvider.offline.graphqlClient({ 12 | shop: res.locals.user_shop, 13 | }); 14 | return res.status(200).send({ content: "Proxy Be Working" }); 15 | } catch (e) { 16 | console.error(e); 17 | return res.status(400).send({ error: true }); 18 | } 19 | }); 20 | 21 | export default proxyRouter; 22 | -------------------------------------------------------------------------------- /server/routes/checkout/index.js: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | const checkoutRoutes = Router(); 3 | 4 | /** 5 | * @param {import('express').Request} req - Express request object 6 | * @param {import('express').Response} res - Express response object 7 | */ 8 | checkoutRoutes.get("/", async (req, res) => { 9 | try { 10 | return res.status(200).send({ message: "It works!" }); 11 | } catch (e) { 12 | console.error(`An error occured at /api/checkout`); 13 | return res.status(400).send({ error: true }); 14 | } 15 | }); 16 | 17 | export default checkoutRoutes; 18 | -------------------------------------------------------------------------------- /server/routes/index.js: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import clientProvider from "../../utils/clientProvider.js"; 3 | 4 | const userRoutes = Router(); 5 | 6 | /** 7 | * @param {import('express').Request} req - Express request object 8 | * @param {import('express').Response} res - Express response object 9 | */ 10 | userRoutes.get("/", (req, res) => { 11 | try { 12 | const sendData = { text: "This is coming from /api/apps/ route." }; 13 | return res.status(200).json(sendData); 14 | } catch (e) { 15 | console.error(e); 16 | return res.status(400).send({ error: true }); 17 | } 18 | }); 19 | 20 | /** 21 | * @param {import('express').Request} req - Express request object 22 | * @param {import('express').Response} res - Express response object 23 | */ 24 | userRoutes.post("/", (req, res) => { 25 | try { 26 | return res.status(200).json(req.body); 27 | } catch (e) { 28 | console.error(e); 29 | return res.status(400).send({ error: true }); 30 | } 31 | }); 32 | 33 | /** 34 | * @param {import('express').Request} req - Express request object 35 | * @param {import('express').Response} res - Express response object 36 | */ 37 | userRoutes.get("/debug/gql", async (req, res) => { 38 | try { 39 | //false for offline session, true for online session 40 | const { client } = await clientProvider.offline.graphqlClient({ 41 | shop: res.locals.user_session.shop, 42 | }); 43 | 44 | const shop = await client.request(/* GraphQL */ ` 45 | { 46 | shop { 47 | name 48 | } 49 | } 50 | `); 51 | 52 | return res.status(200).json({ text: shop.data.shop.name }); 53 | } catch (e) { 54 | console.error(e); 55 | return res.status(400).send({ error: true, text: "GQL Query broke" }); 56 | } 57 | }); 58 | 59 | /** 60 | * @param {import('express').Request} req - Express request object 61 | * @param {import('express').Response} res - Express response object 62 | */ 63 | userRoutes.get("/debug/activeWebhooks", async (req, res) => { 64 | try { 65 | const { client } = await clientProvider.offline.graphqlClient({ 66 | shop: res.locals.user_session.shop, 67 | }); 68 | const activeWebhooks = await client.request(/* GraphQL */ ` 69 | { 70 | webhookSubscriptions(first: 25) { 71 | edges { 72 | node { 73 | topic 74 | endpoint { 75 | __typename 76 | ... on WebhookHttpEndpoint { 77 | callbackUrl 78 | } 79 | } 80 | } 81 | } 82 | } 83 | } 84 | `); 85 | return res.status(200).json(activeWebhooks); 86 | } catch (e) { 87 | console.error(e); 88 | return res.status(400).send({ error: true }); 89 | } 90 | }); 91 | 92 | /** 93 | * @param {import('express').Request} req - Express request object 94 | * @param {import('express').Response} res - Express response object 95 | */ 96 | userRoutes.get("/debug/getActiveSubscriptions", async (req, res) => { 97 | try { 98 | const { client } = await clientProvider.offline.graphqlClient({ 99 | shop: res.locals.user_session.shop, 100 | }); 101 | const response = await client.request(/* GraphQL */ ` 102 | { 103 | appInstallation { 104 | activeSubscriptions { 105 | name 106 | status 107 | lineItems { 108 | plan { 109 | pricingDetails { 110 | ... on AppRecurringPricing { 111 | __typename 112 | price { 113 | amount 114 | currencyCode 115 | } 116 | interval 117 | } 118 | } 119 | } 120 | } 121 | test 122 | } 123 | } 124 | } 125 | `); 126 | 127 | return res.status(200).send(response); 128 | } catch (e) { 129 | console.error(e); 130 | return res.status(400).send({ error: true }); 131 | } 132 | }); 133 | 134 | /** 135 | * @param {import('express').Request} req - Express request object 136 | * @param {import('express').Response} res - Express response object 137 | */ 138 | userRoutes.get("/debug/createNewSubscription", async (req, res) => { 139 | try { 140 | const { client, shop } = await clientProvider.offline.graphqlClient({ 141 | shop: res.locals.user_session.shop, 142 | }); 143 | const returnUrl = `${process.env.SHOPIFY_APP_URL}/?shop=${shop}`; 144 | 145 | const planName = "$10.25 plan"; 146 | const planPrice = 10.25; //Always a decimal 147 | 148 | const response = await client.request( 149 | /* GraphQL */ ` 150 | mutation CreateSubscription( 151 | $name: String! 152 | $lineItems: [AppSubscriptionLineItemInput!]! 153 | $returnUrl: URL! 154 | $test: Boolean 155 | ) { 156 | appSubscriptionCreate( 157 | name: $name 158 | returnUrl: $returnUrl 159 | lineItems: $lineItems 160 | test: $test 161 | ) { 162 | userErrors { 163 | field 164 | message 165 | } 166 | confirmationUrl 167 | appSubscription { 168 | id 169 | status 170 | } 171 | } 172 | } 173 | `, 174 | { 175 | variables: { 176 | name: planName, 177 | returnUrl: returnUrl, 178 | test: true, 179 | lineItems: [ 180 | { 181 | plan: { 182 | appRecurringPricingDetails: { 183 | price: { 184 | amount: planPrice, 185 | currencyCode: "USD", 186 | }, 187 | interval: "EVERY_30_DAYS", 188 | }, 189 | }, 190 | }, 191 | ], 192 | }, 193 | } 194 | ); 195 | 196 | if (response.data.appSubscriptionCreate.userErrors.length > 0) { 197 | console.log( 198 | `--> Error subscribing ${shop} to plan:`, 199 | response.data.appSubscriptionCreate.userErrors 200 | ); 201 | res.status(400).send({ error: "An error occured." }); 202 | return; 203 | } 204 | 205 | return res.status(200).send({ 206 | confirmationUrl: `${response.data.appSubscriptionCreate.confirmationUrl}`, 207 | }); 208 | } catch (e) { 209 | console.error(e); 210 | return res.status(400).send({ error: true }); 211 | } 212 | }); 213 | 214 | export default userRoutes; 215 | -------------------------------------------------------------------------------- /server/webhooks/_index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * DO NOT EDIT THIS FILE DIRECTLY 3 | * Head over to utils/shopify.js to create your webhooks 4 | * and write your webhook functions in server/webhooks. 5 | * If you don't know the format, use the `createwebhook` snippet when using VSCode 6 | * to get a boilerplate function for webhooks. 7 | * To update this file, run `npm run update:config` or `bun run update:config` 8 | */ 9 | 10 | import shopify from "../../utils/shopify.js"; 11 | import appUninstallHandler from "./app_uninstalled.js"; 12 | 13 | const webhookHandler = async (req, res) => { 14 | const topic = req.headers["x-shopify-topic"] || ""; 15 | const shop = req.headers["x-shopify-shop-domain"] || ""; 16 | const apiVersion = req.headers["x-shopify-api-version"] || ""; 17 | const webhookId = req.headers["x-shopify-webhook-id"] || ""; 18 | 19 | try { 20 | const validateWebhook = await shopify.webhooks.validate({ 21 | rawBody: req.body, 22 | rawRequest: req, 23 | rawResponse: res, 24 | }); 25 | 26 | if (validateWebhook.valid) { 27 | } else { 28 | return res.status(400).send({ error: true }); 29 | } 30 | 31 | //SWITCHCASE 32 | switch (validateWebhook.topic) { 33 | case "APP_UNINSTALLED": 34 | await appUninstallHandler(topic, shop, req.body, webhookId, apiVersion); 35 | break; 36 | default: 37 | throw new Error(`Can't find a handler for ${validateWebhook.topic}`); 38 | } 39 | //SWITCHCASE END 40 | console.log(`--> Processed ${topic} webhook for ${shop}`); 41 | return res.status(200).send({ message: "ok" }); 42 | } catch (e) { 43 | console.error( 44 | `---> Error while registering ${topic} webhook for ${shop}`, 45 | e 46 | ); 47 | if (!res.headersSent) { 48 | return res.status(500).send(e.message); 49 | } 50 | } 51 | }; 52 | 53 | export default webhookHandler; 54 | -------------------------------------------------------------------------------- /server/webhooks/app_uninstalled.js: -------------------------------------------------------------------------------- 1 | import SessionModel from "../../utils/models/SessionModel.js"; 2 | import StoreModel from "../../utils/models/StoreModel.js"; 3 | 4 | /** 5 | * @typedef { import("../../_developer/types/2025-04/webhooks.js").APP_UNINSTALLED } webhookTopic 6 | */ 7 | 8 | const appUninstallHandler = async ( 9 | topic, 10 | shop, 11 | webhookRequestBody, 12 | webhookId, 13 | apiVersion 14 | ) => { 15 | /** @type {webhookTopic} */ 16 | const webhookBody = JSON.parse(webhookRequestBody); 17 | await StoreModel.findOneAndUpdate({ shop }, { isActive: false }); 18 | await SessionModel.deleteMany({ shop }); 19 | }; 20 | 21 | export default appUninstallHandler; 22 | -------------------------------------------------------------------------------- /utils/clientProvider.js: -------------------------------------------------------------------------------- 1 | import sessionHandler from "./sessionHandler.js"; 2 | import shopify from "./shopify.js"; 3 | 4 | /** 5 | * Fetches the offline session associated with a shop. 6 | * @async 7 | * @param {string} shop - The shop's domain. 8 | */ 9 | const fetchOfflineSession = async (shop) => { 10 | const sessionID = shopify.session.getOfflineId(shop); 11 | const session = await sessionHandler.loadSession(sessionID); 12 | return session; 13 | }; 14 | 15 | /** 16 | * Provides methods to create clients for offline access. 17 | * @namespace offline 18 | */ 19 | const offline = { 20 | /** 21 | * Creates a Shopify GraphQL client for offline access. 22 | * @async 23 | * @param {Object} params - The request and response objects. 24 | * @param {string} params.shop - The shop's domain 25 | */ 26 | graphqlClient: async ({ shop }) => { 27 | const session = await fetchOfflineSession(shop); 28 | const client = new shopify.clients.Graphql({ session }); 29 | return { client, shop, session }; 30 | }, 31 | /** 32 | * Creates a Shopify Storefront client for offline access. 33 | * @async 34 | * @param {Object} params - The request and response objects. 35 | * @param {string} params.shop - The shop's domain 36 | */ 37 | storefrontClient: async ({ shop }) => { 38 | const session = await fetchOfflineSession(shop); 39 | const client = new shopify.clients.Storefront({ session }); 40 | return { client, shop, session }; 41 | }, 42 | }; 43 | 44 | /** 45 | * Fetches the online session associated with a request. 46 | * @async 47 | * @param {Object} params - The request and response objects. 48 | * @param {import('express').Request} params.req - The Express request object 49 | * @param {import('express').Response} params.res - The Express response object 50 | */ 51 | const fetchOnlineSession = async ({ req, res }) => { 52 | const sessionID = await shopify.session.getCurrentId({ 53 | isOnline: true, 54 | rawRequest: req, 55 | rawResponse: res, 56 | }); 57 | const session = await sessionHandler.loadSession(sessionID); 58 | return session; 59 | }; 60 | 61 | /** 62 | * Provides methods to create clients for online access. 63 | * @namespace online 64 | */ 65 | const online = { 66 | /** 67 | * Creates a Shopify GraphQL client for online access. 68 | * @async 69 | * @param {Object} params - The request and response objects. 70 | * @param {import('express').Request} params.req - The Express request object 71 | * @param {import('express').Response} params.res - The Express response object 72 | */ 73 | graphqlClient: async ({ req, res }) => { 74 | const session = await fetchOnlineSession({ req, res }); 75 | const client = new shopify.clients.Graphql({ session }); 76 | const { shop } = session; 77 | return { client, shop, session }; 78 | }, 79 | /** 80 | * Creates a Shopify GraphQL client for online access. 81 | * @async 82 | * @param {Object} params - The request and response objects. 83 | * @param {import('express').Request} params.req - The Express request object 84 | * @param {import('express').Response} params.res - The Express response object 85 | */ 86 | storefrontClient: async ({ req, res }) => { 87 | const session = await fetchOnlineSession({ req, res }); 88 | const client = new shopify.clients.Storefront({ session }); 89 | const { shop } = session; 90 | return { client, shop, session }; 91 | }, 92 | }; 93 | 94 | /** 95 | * Provides GraphQL client providers for both online and offline access. 96 | * @namespace clientProvider 97 | */ 98 | const clientProvider = { 99 | offline, 100 | online, 101 | }; 102 | 103 | export default clientProvider; 104 | -------------------------------------------------------------------------------- /utils/freshInstall.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * It's relatively easy to overload this function that will result in a long first open time. 4 | * If something can happen in the background, don't `await FreshInstall()` and instead just 5 | * `FreshInstall()` in isInitialLoad function. 6 | * 7 | */ 8 | import StoreModel from "./models/StoreModel.js"; 9 | 10 | const freshInstall = async ({ shop }) => { 11 | console.log("This is a fresh install - run functions"); 12 | await StoreModel.findOneAndUpdate( 13 | { shop: shop }, 14 | { isActive: true }, 15 | { upsert: true } 16 | ); 17 | }; 18 | 19 | export default freshInstall; 20 | -------------------------------------------------------------------------------- /utils/models/SessionModel.js: -------------------------------------------------------------------------------- 1 | // Session store model to preserve sessions across restarts. 2 | import mongoose from "mongoose"; 3 | 4 | const sessionSchema = new mongoose.Schema({ 5 | id: { 6 | type: String, 7 | required: true, 8 | }, 9 | content: { 10 | type: String, 11 | required: true, 12 | }, 13 | shop: { 14 | type: String, 15 | required: true, 16 | }, 17 | }); 18 | 19 | const SessionModel = mongoose.model("session", sessionSchema); 20 | 21 | export default SessionModel; 22 | -------------------------------------------------------------------------------- /utils/models/StoreModel.js: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | 3 | const StoreSchema = new mongoose.Schema({ 4 | shop: { type: String, required: true, unique: true }, 5 | isActive: { type: Boolean, required: true, default: false }, 6 | }); 7 | 8 | const StoreModel = mongoose.model("Active_Stores", StoreSchema); 9 | 10 | export default StoreModel; 11 | -------------------------------------------------------------------------------- /utils/sessionHandler.js: -------------------------------------------------------------------------------- 1 | import { Session } from "@shopify/shopify-api"; 2 | import Cryptr from "cryptr"; 3 | import SessionModel from "./models/SessionModel.js"; 4 | 5 | const cryption = new Cryptr(process.env.ENCRYPTION_STRING); 6 | 7 | /** 8 | * Stores the session data into the database. 9 | * 10 | * @param {Session} session - The Shopify session object. 11 | * @returns {Promise} Returns true if the operation was successful. 12 | */ 13 | const storeSession = async (session) => { 14 | await SessionModel.findOneAndUpdate( 15 | { id: session.id }, 16 | { 17 | content: cryption.encrypt(JSON.stringify(session)), 18 | shop: session.shop, 19 | }, 20 | { upsert: true } 21 | ); 22 | 23 | return true; 24 | }; 25 | 26 | /** 27 | * Loads the session data from the database. 28 | * 29 | * @param {string} id - The session ID. 30 | * @returns {Promise} Returns the Shopify session object or 31 | * undefined if not found. 32 | */ 33 | const loadSession = async (id) => { 34 | const sessionResult = await SessionModel.findOne({ id }); 35 | if (sessionResult === null) { 36 | return undefined; 37 | } 38 | if (sessionResult.content.length > 0) { 39 | const sessionObj = JSON.parse(cryption.decrypt(sessionResult.content)); 40 | const returnSession = new Session(sessionObj); 41 | return returnSession; 42 | } 43 | return undefined; 44 | }; 45 | 46 | /** 47 | * Deletes the session data from the database. 48 | * 49 | * @param {string} id - The session ID. 50 | * @returns {Promise} Returns true if the operation was successful. 51 | */ 52 | const deleteSession = async (id) => { 53 | await SessionModel.deleteMany({ id }); 54 | return true; 55 | }; 56 | 57 | /** 58 | * Session handler object containing storeSession, loadSession, and 59 | * deleteSession functions. 60 | */ 61 | const sessionHandler = { storeSession, loadSession, deleteSession }; 62 | 63 | export default sessionHandler; 64 | -------------------------------------------------------------------------------- /utils/setupCheck.js: -------------------------------------------------------------------------------- 1 | const setupCheck = () => { 2 | const { 3 | SHOPIFY_API_KEY: apiKey, 4 | SHOPIFY_API_SECRET: apiSecret, 5 | SHOPIFY_API_SCOPES: apiScopes, 6 | SHOPIFY_APP_URL: appUrl, 7 | SHOPIFY_API_VERSION: apiVersion, 8 | ENCRYPTION_STRING: encString, 9 | PORT: port, 10 | NPM_CONFIG_FORCE: forceInstall, 11 | MONGO_URL: databaseURL, 12 | } = process.env; 13 | 14 | let errorCount = 0; 15 | 16 | if (typeof apiKey === "undefined") { 17 | console.error("---> API Key is undefined."); 18 | errorCount++; 19 | } 20 | if (typeof apiSecret === "undefined") { 21 | console.error("---> API Secret is undefined."); 22 | errorCount++; 23 | } 24 | if (typeof apiScopes === "undefined") { 25 | console.error("---> API Scopes are undefined."); 26 | errorCount++; 27 | } 28 | if (typeof appUrl === "undefined") { 29 | console.error("---> App URL is undefined."); 30 | errorCount++; 31 | } else if (!appUrl.includes("https://")) { 32 | console.error("---> Please use HTTPS for SHOPIFY_APP_URL."); 33 | } 34 | if (typeof apiVersion === "undefined") { 35 | console.error("---> API Version is undefined."); 36 | errorCount++; 37 | } 38 | if (typeof encString === "undefined") { 39 | console.error("---> Encryption String is undefined."); 40 | errorCount++; 41 | } 42 | if (typeof port === "undefined") { 43 | if (process.env.NODE_ENV !== "dev") { 44 | console.warn("--> Port is undefined. Using 8081"); 45 | errorCount++; 46 | } 47 | } 48 | 49 | if (typeof databaseURL === "undefined") { 50 | console.error("---> Database string is undefined."); 51 | errorCount++; 52 | } 53 | 54 | if (!forceInstall) { 55 | console.error( 56 | `--> Set NPM_CONFIG_FORCE to true so server uses "npm i --force" and install dependencies successfully` 57 | ); 58 | errorCount++; 59 | } 60 | 61 | if (errorCount > 4) { 62 | console.error( 63 | "\n\n\n\n--> .env file is either not reachable or not setup properly. Please refer to .env.example file for the setup.\n\n\n\n" 64 | ); 65 | } 66 | 67 | if (errorCount == 0) { 68 | console.log("--> Setup checks passed successfully."); 69 | } 70 | }; 71 | 72 | export default setupCheck; 73 | -------------------------------------------------------------------------------- /utils/shopify.js: -------------------------------------------------------------------------------- 1 | import { LogSeverity, shopifyApi } from "@shopify/shopify-api"; 2 | import "@shopify/shopify-api/adapters/node"; 3 | import "dotenv/config"; 4 | import appUninstallHandler from "../server/webhooks/app_uninstalled.js"; 5 | 6 | const isDev = process.env.NODE_ENV === "dev"; 7 | 8 | // Setup Shopify configuration 9 | let shopify = shopifyApi({ 10 | apiKey: process.env.SHOPIFY_API_KEY, 11 | apiSecretKey: process.env.SHOPIFY_API_SECRET, 12 | scopes: process.env.SHOPIFY_API_SCOPES, 13 | hostName: process.env.SHOPIFY_APP_URL.replace(/https:\/\//, ""), 14 | hostScheme: "https", 15 | apiVersion: process.env.SHOPIFY_API_VERSION, 16 | isEmbeddedApp: true, 17 | logger: { level: isDev ? LogSeverity.Info : LogSeverity.Error }, 18 | }); 19 | 20 | /* 21 | Template for adding new topics: 22 | ``` 23 | { 24 | topics: ["",""] //Get this from `https://shopify.dev/docs/api/webhooks?reference=toml` 25 | url: "/api/webhooks/topic_name" //this can be AWS, PubSub or HTTP routes. 26 | callback: () //This HAS to be in utils/webhooks/ and created with the `createwebhook` snippet. 27 | filter: "" //Optional - filter what webhooks you recieve 28 | include_fields: ["",""] //Optional - decide what fields you want to recieve 29 | } 30 | ``` 31 | */ 32 | 33 | //Add custom user properties to base shopify obj 34 | shopify = { 35 | ...shopify, 36 | user: { 37 | /** 38 | * @type {Array<{ 39 | * topics: import("../_developer/types/webhookTopics.js").WebhookTopics["topic"], 40 | * url: string, 41 | * callback: Function, 42 | * filter?: string, 43 | * include_fields?: string[] 44 | * }>} 45 | */ 46 | webhooks: [ 47 | { 48 | topics: ["app/uninstalled"], 49 | url: "/api/webhooks/app_uninstalled", 50 | callback: appUninstallHandler, 51 | }, 52 | ], 53 | }, 54 | }; 55 | 56 | export default shopify; 57 | -------------------------------------------------------------------------------- /utils/validateJWT.js: -------------------------------------------------------------------------------- 1 | import crypto from "crypto"; 2 | 3 | /** 4 | * 5 | * Validate your JWT token against the secret. 6 | * @param {String} token - JWT Token 7 | * @param {String} secret - Signature secret. By default uses the `process.env.SHOPIFY_API_SECRET` value 8 | * @returns {Object} Decoded JWT payload. 9 | */ 10 | function validateJWT(token, secret = process.env.SHOPIFY_API_SECRET) { 11 | // Split the token into parts 12 | const parts = token.split("."); 13 | if (parts.length !== 3) { 14 | throw new Error("JWT: Token structure incorrect"); 15 | } 16 | 17 | const header = parts[0]; 18 | const payload = parts[1]; 19 | const signature = parts[2]; 20 | 21 | // Base64 decode and parse the header and payload 22 | const headerJson = Buffer.from(header, "base64").toString(); 23 | const payloadJson = Buffer.from(payload, "base64").toString(); 24 | 25 | // Verify the signature 26 | const signatureCheck = crypto 27 | .createHmac("sha256", secret) 28 | .update(`${header}.${payload}`) 29 | .digest("base64"); 30 | 31 | // Replace '+' with '-', '/' with '_', and remove '=' 32 | const safeSignatureCheck = signatureCheck 33 | .replace(/\+/g, "-") 34 | .replace(/\//g, "_") 35 | .replace(/=+$/, ""); 36 | 37 | if (safeSignatureCheck !== signature) { 38 | throw new Error("Invalid token signature"); 39 | } 40 | 41 | // Optionally, you can add more checks here for the payload 42 | // e.g., check the expiration, issuer, audience, etc. 43 | 44 | return JSON.parse(payloadJson); 45 | } 46 | 47 | export default validateJWT; 48 | --------------------------------------------------------------------------------