├── .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 | " ",
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 | [](https://kinngh.gumroad.com/l/how-to-make-shopify-apps?utm_source=github&utm_medium=express-repo)
10 |
11 | [](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 |
--------------------------------------------------------------------------------