Shopify app billing 

Shopify's app platform supports charging Shopify merchants for using your application. Applications built using Gadget's Shopify Connection use the existing Shopify Billing APIs for creating charges, and money is moved using Shopify's standard partner payout system.

Read about Shopify's Billing API here in Shopify's docs.

Different applications may choose from various billing schemes, all of which are supported by Gadget. Choose which billing scheme makes the most sense for your application, and then create the right AppSubscription or AppPurchaseOneTime objects using the Shopify GraphQL API to implement your billing scheme. You also may need to build a frontend interface for users to select from among your plans, upgrade and downgrade, and view their usage of your application.

SchemeSuggested Implementation
Free appNo action required
Fixed Monthly feeCreate AppSubscription in the Shopify Shop model's Install action onSuccess functionShopify docs
One time feeCreate AppPurchaseOneTime in the Shopify Shop model's Install action onSuccess functionShopify docs
Usage charge per key actionCreate AppPurchaseOneTime in key actions within Shopify or your own modelsShopify docs
2 or more plans with monthly feesCreate AppSubscription in a new subscribe action on the Shopify Shop modelShopify docs
Free trial, monthly fee afterCheck Shop install date within actions, show a plan selection screen in your application, add a subscribe action to the Shopify Shop model and create an AppSubscription for your plan in itShopify docs

Want to see a working example using test charges? You can fork this Gadget project and try it out yourself. You need to make sure your Partners app distribution is set to App Store for the test charges to be completed. The project includes a basic example of monthly subscriptions.

Fork on Gadget

Charge flow 

To ensure the activation and movement of funds, charges created through API calls to Shopify require merchant acceptance. Once a charge object is created, it will be in a pending state until the merchant accepts it. Freshly created charge objects provide a confirmationUrl that should be shared with the merchant. The merchant must visit this URL to accept the charge.

It is essential to note that payment should only be considered confirmed after the merchant has accepted the charge. Merchants will be directed back to the returnUrl specified during charge creation by Shopify once they have accepted the charge. This allows for seamless redirection and completion of the payment process.

Read more about the billing process in Shopify's docs.

Creating charges 

Charge objects which result in merchants paying you money are created by making calls to the Shopify API. You can use Gadget's existing API client object to do this within actions and HTTP routes with the connections.shopify.current object.

For example, we can create an AppSubscription as soon as our application gets installed by adding a code snippet to the onSuccess function of the Install action on the Shopify Shop model. This onSuccess function will make a call to Shopify and get back a confirmationUrl to send the merchant to. We send or store this URL, send the merchant to it on the frontend, and if the merchant accepts the charge, Shopify will send them to the /finish-payment HTTP route, where we can set up their access to the application.

api/models/shopifyShop/actions/install.js
JavaScript
1export const onSuccess: ActionOnSuccess = async ({
2 params,
3 record,
4 logger,
5 api,
6 connections,
7}) => {
8 // get an instance of the shopify-api-node API client for this shop
9 const shopify = connections.shopify.current;
10 if (!shopify) {
11 throw new Error("Missing Shopify connection");
12 }
13
14 const result = await shopify.graphql(`
15 mutation {
16 appPurchaseOneTimeCreate(
17 name: "Basic charge"
18 price: { amount: 100.00, currencyCode: USD }
19 returnUrl: "https://example-app.gadget.app/finish-payment?shop_id=${connections.shopify.currentShopId}"
20 ) {
21 userErrors {
22 field
23 message
24 }
25 confirmationUrl
26 appPurchaseOneTime {
27 id
28 }
29 }
30 }
31`);
32
33 const { confirmationUrl, appPurchaseOneTime } = result.appPurchaseOneTimeCreate;
34
35 // store the `result.confirmationUrl` that the merchant needs to visit
36 await api.internal.shopifyShop.update(record.id, { confirmationUrl });
37
38 logger.info(
39 { appPurchaseOneTimeId: appPurchaseOneTime.id },
40 "created one time app purchase"
41 );
42};
1export const onSuccess: ActionOnSuccess = async ({
2 params,
3 record,
4 logger,
5 api,
6 connections,
7}) => {
8 // get an instance of the shopify-api-node API client for this shop
9 const shopify = connections.shopify.current;
10 if (!shopify) {
11 throw new Error("Missing Shopify connection");
12 }
13
14 const result = await shopify.graphql(`
15 mutation {
16 appPurchaseOneTimeCreate(
17 name: "Basic charge"
18 price: { amount: 100.00, currencyCode: USD }
19 returnUrl: "https://example-app.gadget.app/finish-payment?shop_id=${connections.shopify.currentShopId}"
20 ) {
21 userErrors {
22 field
23 message
24 }
25 confirmationUrl
26 appPurchaseOneTime {
27 id
28 }
29 }
30 }
31`);
32
33 const { confirmationUrl, appPurchaseOneTime } = result.appPurchaseOneTimeCreate;
34
35 // store the `result.confirmationUrl` that the merchant needs to visit
36 await api.internal.shopifyShop.update(record.id, { confirmationUrl });
37
38 logger.info(
39 { appPurchaseOneTimeId: appPurchaseOneTime.id },
40 "created one time app purchase"
41 );
42};

See accessing the Shopify API for more info on using the connections.shopify object.

To grant a merchant who has accepted this charge access to our application, we can set up some state in our database in a api/routes/GET-finish-payment.js HTTP route in our Gadget app. This route powers the returnUrl we send to Shopify when we create the charge object above.

api/routes/GET-finish-payment.js
JavaScript
1import { RouteHandler } from "gadget-server";
2const route: RouteHandler<{
3 Querystring: { shop_id: string; charge_id: string };
4}> = async ({ request, reply, api, connections }) => {
5 // get an instance of the shopify-api-node API client for this shop
6 const shopify = await connections.shopify.forShopId(request.query.shop_id);
7
8 // make an API call to Shopify to validate that the charge object for this shop is active
9 const result = await shopify.graphql(`
10 query {
11 node(id: "gid://shopify/AppSubscription/${request.query.charge_id}") {
12 id
13 ... on AppSubscription {
14 status
15 }
16 }
17 }
18 `);
19
20 if (result.node.status != "ACTIVE") {
21 // the merchant has not accepted the charge, so we can show them a message
22 await reply.code(400).send("Invalid charge ID specified");
23 return;
24 }
25 // the merchant has accepted the charge, so we can grant them access to our application
26 // example: mark the shop as paid by setting a `plan` attribute, this may vary for your billing model
27 await api.internal.shopifyShop.update(request.query.shop_id, { plan: "basic" });
28
29 // send the user back to the embedded app
30 await reply.redirect("/");
31};
32
33export default route;
1import { RouteHandler } from "gadget-server";
2/** @type { RouteHandler<{ Querystring: { shop_id: string, charge_id: string; }; }> }*/
3const route = async ({ request, reply, api, logger, connections }) => {
4 // get an instance of the shopify-api-node API client for this shop
5 const shopify = await connections.shopify.forShopId(request.query.shop_id);
6
7 // make an API call to Shopify to validate that the charge object for this shop is active
8 const result = await shopify.graphql(`
9 query {
10 node(id: "gid://shopify/AppSubscription/${request.query.charge_id}") {
11 id
12 ... on AppSubscription {
13 status
14 }
15 }
16 }
17 `);
18
19 if (result.node.status != "ACTIVE") {
20 // the merchant has not accepted the charge, so we can show them a message
21 await reply.code(400).send("Invalid charge ID specified");
22 return;
23 }
24 // the merchant has accepted the charge, so we can grant them access to our application
25 // example: mark the shop as paid by setting a `plan` attribute, this may vary for your billing model
26 await api.internal.shopifyShop.update(request.query.shop_id, { plan: "basic" });
27
28 // send the user back to the embedded app
29 await reply.redirect("/");
30};
31
32export default route;

Free apps 

Apps that don't need to charge merchants don't need to create any app charges. Merchants will install your application and complete the OAuth process, and then your application can begin working on behalf of the merchant with no other intervention needed. If you start charging in the future, you can later create charges for existing merchants using these recipes.

Subscribing to a recurring plan 

Shopify's Partner Billing API supports charging merchants a monthly fee for access to your application. For example, a product quiz application could charge $15.00 per month for the ability to run one quiz. If your application has multiple differently priced plans for merchants, you need to implement functionality for merchants to select plans. You can then create recurring charges depending on which plan is selected. This plan selection interface is implemented within your application once it is installed. A merchant will install your application from the Shopify App Store, complete the OAuth process, and Gadget will create a Shopify Shop record in your application's database. Once the app is installed, merchants can visit your application in their Shopify Admin, and you can then show them your plan selection interface.

Within your plan selection interface, you can then make a call back to your application with the plan a merchant has selected. Gadget recommends adding a subscribe action to your Shopify Shop model, which you can then call from your frontend, and creating an AppSubscription resource in the Shopify API to represent the selected plan.

For example, in our Gadget backend, we can create several things:

  • A new string field on the Shopify Shop model called Plan to track which plan a merchant has selected.
  • A new url field on the Shopify Shop model called Confirmation URL to track where to send the merchant to confirm the payment information for their plan.
  • A subscribe action on the Shopify Shop model for implementing plan selection.
  • A api/routes/GET-finish-payment.js HTTP route to use as the returnUrl when creating charges for confirming charge acceptance.

The best way to keep track of plans is to create a Plan model and add fields related to this plan, such as name, price, and description. A has many relationship to the Shopify Shop model is also required to keep track of which Shopify Shop belongs to which Plan, making it easy to then retrieve plan data for a specific shop.

First, let's make a subscribe action on the Shopify Shop model. We can add a code snippet that creates the charge with Shopify to present to the merchant. First, under run function, remove the Update Record effect and add a Run Code Snippet in its place. Name the code snippet createAppCharge.js and add the following content:

api/models/shopifyShop/actions/createAppCharge.js
JavaScript
1const PLANS: Record<string, { price: number }> = {
2 basic: {
3 price: 10.0,
4 },
5 pro: {
6 price: 20.0,
7 },
8 enterprise: {
9 price: 100.0,
10 },
11};
12
13export const run: ActionRun = async ({
14 record,
15 params,
16 logger,
17 api,
18 connections,
19}) => {
20 // get the plan object from the list of available plans
21 const name = params.plan;
22 const plan = PLANS[name as keyof typeof PLANS];
23 if (!plan) throw new Error(`unknown plan name ${name}`);
24
25 // get an instance of the shopify-api-node API client for this shop
26 const shopify = connections.shopify.current;
27 if (!shopify) {
28 throw new Error(`Missing shopify connection`);
29 }
30
31 const CREATE_SUBSCRIPTION_QUERY = `
32 mutation CreateSubscription($name: String!, $price: Decimal!) {
33 appSubscriptionCreate(
34 name: $name,
35 test: true,
36 returnUrl: "http://example-app.gadget.app/finish-payment?shop_id=${connections.shopify.currentShopId}",
37 lineItems: [{
38 plan: {
39 appRecurringPricingDetails: {
40 price: { amount: $price, currencyCode: USD }
41 interval: EVERY_30_DAYS
42 }
43 }
44 }]
45 ) {
46 userErrors {
47 field
48 message
49 }
50 confirmationUrl
51 appSubscription {
52 id
53 }
54 }
55 }
56 `;
57 // make an API call to Shopify to create a charge object
58 const result = await shopify.graphql(CREATE_SUBSCRIPTION_QUERY, {
59 name,
60 price: plan.price,
61 });
62
63 const { confirmationUrl, appSubscription } = result.appSubscriptionCreate;
64
65 // update this shop record to send the confirmation URL back to the frontend
66 await api.internal.shopifyShop.update(record.id, { confirmationUrl });
67
68 logger.info({ appSubscriptionId: appSubscription.id }, "created subscription");
69};
70
71// add a parameter to this action to accept which plan name the merchant has selected
72export const params = {
73 plan: { type: "string" },
74};
1const PLANS: Record<string, { price: number }> = {
2 basic: {
3 price: 10.0,
4 },
5 pro: {
6 price: 20.0,
7 },
8 enterprise: {
9 price: 100.0,
10 },
11};
12
13export const run: ActionRun = async ({
14 record,
15 params,
16 logger,
17 api,
18 connections,
19}) => {
20 // get the plan object from the list of available plans
21 const name = params.plan;
22 const plan = PLANS[name as keyof typeof PLANS];
23 if (!plan) throw new Error(`unknown plan name ${name}`);
24
25 // get an instance of the shopify-api-node API client for this shop
26 const shopify = connections.shopify.current;
27 if (!shopify) {
28 throw new Error(`Missing shopify connection`);
29 }
30
31 const CREATE_SUBSCRIPTION_QUERY = `
32 mutation CreateSubscription($name: String!, $price: Decimal!) {
33 appSubscriptionCreate(
34 name: $name,
35 test: true,
36 returnUrl: "http://example-app.gadget.app/finish-payment?shop_id=${connections.shopify.currentShopId}",
37 lineItems: [{
38 plan: {
39 appRecurringPricingDetails: {
40 price: { amount: $price, currencyCode: USD }
41 interval: EVERY_30_DAYS
42 }
43 }
44 }]
45 ) {
46 userErrors {
47 field
48 message
49 }
50 confirmationUrl
51 appSubscription {
52 id
53 }
54 }
55 }
56 `;
57 // make an API call to Shopify to create a charge object
58 const result = await shopify.graphql(CREATE_SUBSCRIPTION_QUERY, {
59 name,
60 price: plan.price,
61 });
62
63 const { confirmationUrl, appSubscription } = result.appSubscriptionCreate;
64
65 // update this shop record to send the confirmation URL back to the frontend
66 await api.internal.shopifyShop.update(record.id, { confirmationUrl });
67
68 logger.info({ appSubscriptionId: appSubscription.id }, "created subscription");
69};
70
71// add a parameter to this action to accept which plan name the merchant has selected
72export const params = {
73 plan: { type: "string" },
74};

With this action in place, we need to implement the returnUrl Shopify will send merchants who have accepted the charge to. This URL is where we should mark the shop as being on a plan, as this is the point at which we know the merchant will be charged. We add a api/routes/GET-finish-payment.js file to match the /finish-payment portion of the returnUrl specified when we created the charge:

api/routes/GET-finish-payment.js
JavaScript
1import { RouteHandler } from "gadget-server";
2const route: RouteHandler<{
3 Querystring: { shop_id: string; charge_id: string };
4}> = async ({ request, reply, api, connections }) => {
5 // get an instance of the shopify-api-node API client for this shop
6 const shopify = await connections.shopify.forShopId(request.query.shop_id);
7
8 // make an API call to Shopify to validate that the charge object for this shop is active
9 const result = await shopify.graphql(`
10 query {
11 node(id: "gid://shopify/AppSubscription/${request.query.charge_id}") {
12 id
13 ... on AppSubscription {
14 status
15 name
16 }
17 }
18 }
19 `);
20
21 if (result.node.status != "ACTIVE") {
22 // the merchant has not accepted the charge, so we can show them a message
23 await reply.code(400).send("Invalid charge ID specified");
24 return;
25 }
26 // the merchant has accepted the charge, so we can grant them access to our application
27 // example: mark the shop as paid by setting a `plan` attribute, this may vary for your billing model
28 await api.internal.shopifyShop.update(request.query.shop_id, { plan: "basic" });
29
30 // send the user back to the embedded app
31 await reply.redirect("/");
32};
33
34export default route;
1import { RouteHandler } from "gadget-server";
2/** @type { RouteHandler<{ Querystring: { shop_id: string, charge_id: string; }; }> }*/
3const route = async ({ request, reply, api, logger, connections }) => {
4 // get an instance of the shopify-api-node API client for this shop
5 const shopify = await connections.shopify.forShopId(request.query.shop_id);
6
7 // make an API call to Shopify to validate that the charge object for this shop is active
8 const result = await shopify.graphql(`
9 query {
10 node(id: "gid://shopify/AppSubscription/${request.query.charge_id}") {
11 id
12 ... on AppSubscription {
13 status
14 name
15 }
16 }
17 }
18 `);
19
20 if (result.node.status != "ACTIVE") {
21 // the merchant has not accepted the charge, so we can show them a message
22 await reply.code(400).send("Invalid charge ID specified");
23 return;
24 }
25 // the merchant has accepted the charge, so we can grant them access to our application
26 // example: mark the shop as paid by setting a `plan` attribute, this may vary for your billing model
27 await api.internal.shopifyShop.update(request.query.shop_id, { plan: "basic" });
28
29 // send the user back to the embedded app
30 await reply.redirect("/");
31};
32
33export default route;

With the subscribe action and confirmation pieces in place, we now need to trigger the new subscribe action from our merchant-facing frontend. We need to run the subscribe action to create the recurring charge and then redirect the merchant to Shopify's confirmation page to accept the charge. If you're using your app's JavaScript client, you could run:

JavaScript
1import { useNavigate } from "react-router";
2
3const navigate = useNavigate();
4const shop = await api.shopifyShop.subscribe(theShopId, { plan: "basic" });
5
6navigate(shop.confirmationUrl);
1import { useNavigate } from "react-router";
2
3const navigate = useNavigate();
4const shop = await api.shopifyShop.subscribe(theShopId, { plan: "basic" });
5
6navigate(shop.confirmationUrl);

Or, if you're using React, you could run this action with the useAction hook from @gadgetinc/react:

React
1import { useCallback } from "react";
2import { useNavigate } from "react-router";
3import { useAction } from "@gadgetinc/react";
4import { api } from "../api";
5
6const desiredShop = { id: "1234" };
7
8export const PlanSelectorButton = () => {
9 const [{ fetching, error, data }, createSubscription] = useAction(api.shopifyShop.subscribe);
10 const navigate = useNavigate();
11
12 const subscribe = useCallback(
13 async (plan: string) => {
14 // create the resource in the backend
15 const shop = await createSubscription(desiredShop, { plan });
16 // redirect the merchant to accept the charge within Shopify's interface
17 navigate(shop.data.confirmationUrl);
18 },
19 [createSubscription]
20 );
21
22 return (
23 <button onClick={() => subscribe("basic")} disabled={fetching}>
24 Basic
25 </button>
26 );
27};
1import { useCallback } from "react";
2import { useNavigate } from "react-router";
3import { useAction } from "@gadgetinc/react";
4import { api } from "../api";
5
6const desiredShop = { id: "1234" };
7
8export const PlanSelectorButton = () => {
9 const [{ fetching, error, data }, createSubscription] = useAction(api.shopifyShop.subscribe);
10 const navigate = useNavigate();
11
12 const subscribe = useCallback(
13 async (plan: string) => {
14 // create the resource in the backend
15 const shop = await createSubscription(desiredShop, { plan });
16 // redirect the merchant to accept the charge within Shopify's interface
17 navigate(shop.data.confirmationUrl);
18 },
19 [createSubscription]
20 );
21
22 return (
23 <button onClick={() => subscribe("basic")} disabled={fetching}>
24 Basic
25 </button>
26 );
27};

Upgrades and downgrades 

Merchants may decide they need more or less of your app's features over time. If you offer multiple different plans, you need a plan selection interface that merchants who have already selected a plan can revisit to select a new plan. Plan upgrading or downgrading is implemented natively by Shopify, and the new plan is registered in the same way as an existing plan. Shopify allows your app to have only one AppSubscription object created at a time. This means that when a merchant changes plans and you send an API call to Shopify to create a new AppSubscription, it will automatically replace the old one. The merchant then will only be charged for the new amount on the new AppSubscription object.

For example, say a merchant is upgrading from a Basic plan, which costs $5 a month, to a Pro plan, which costs $10 a month. When the merchant first installed your app, your app created the $5 AppSubscription. When they revisit the plan selector and select the $10 plan, your app can immediately create a new $10 AppSubscription, and Shopify will replace the $5 subscription and figure out the prorating and billing cycle details. You don't need to delete the $5 AppSubscription object yourself.

Read more about plan changes and prorating in Shopify's docs.

Preventing access without payment 

Shopify's API requires you to allow merchants to install your application before they have set up payment terms with you. This means that your app will technically be installed on merchants who may not have selected a plan or who may have enjoyed their free trial but yet to select a plan. Because of this, you should disable access to the key parts of your application until a merchant selects a plan, and encourage them to do so.

Disable access to reading records from your application using model filters and disable app behavior by using Run Code Snippets in your actions.

For example, a merchant's payment state can be stored in a Plan string field on the Shopify Shop model. When a merchant first installs the application, the plan will be null, and then when they select a plan and accept the charge, the plan field can be updated to hold whichever plan the merchant has selected. With this in place, you can begin to conditionally perform your application's duties for paying customers only. For example, for an application that analyzes order fraud, we can only do the fraud analysis if the merchant is on a plan:

api/models/shopifyOrder/actions/analyzeForFraud.js
JavaScript
1import { doFraudAnalysis } from "./paidPlanFunctions";
2
3export const run: ActionRun = async ({ record, api, connections }) => {
4 // "record" is a Shopify Order record, load the Shopify Shop record for this order
5 const shop = await api.shopifyShop.findOne(connections.shopify.currentShopId);
6
7 // only do the processing for this action if the shop is on a paid plan
8 if (shop.plan !== null) {
9 await doFraudAnalysis(record);
10 }
11 // otherwise, the shop hasn't selected a plan and isn't paying, don't perform the analysis
12};
1import { doFraudAnalysis } from "./paidPlanFunctions";
2
3export const run: ActionRun = async ({ record, api, connections }) => {
4 // "record" is a Shopify Order record, load the Shopify Shop record for this order
5 const shop = await api.shopifyShop.findOne(connections.shopify.currentShopId);
6
7 // only do the processing for this action if the shop is on a paid plan
8 if (shop.plan !== null) {
9 await doFraudAnalysis(record);
10 }
11 // otherwise, the shop hasn't selected a plan and isn't paying, don't perform the analysis
12};

If need be, you can also extend your model read permissions to prevent access to records. You can update the Gelly model filter snippet in Roles & Permissions to only return records for paid merchants. For example, if you have a Fraud Result model which belongs to the Shopify Shop model, Fraud Result records can be programmatically returned depending on the plan selected:

gelly
filter ($session: Session) on FraudResult [
where !isNull(shop.plan)
]

Often, you may want to disable access to your application's merchant-facing frontend if the merchant hasn't yet paid for the application. This can be done in React using a wrapper component that checks the plan status for every page the merchant tries to access:

components/SubscriptionWrapper.jsx
React
1import { Layout, Page, Spinner, Banner } from "@shopify/polaris";
2import type { ReactNode } from "react";
3import { api } from "../api";
4import { useFindOne } from "@gadgetinc/react";
5
6// TODO - Implement a PlanSelector component to address your plan management requirements
7import { PlanSelector } from "./PlanSelector";
8
9export const SubscriptionWrapper = (props: { shopId: string; children: ReactNode }) => {
10 const [{ fetching, data: currentShop }] = useFindOne(api.shopifyShop, props.shopId);
11 // if we're loading the current shop data, show a spinner
12 if (fetching || !currentShop) {
13 return (
14 <Page>
15 <Spinner />
16 </Page>
17 );
18 }
19
20 // if the shop has selected a plan, render the app and don't bug the merchant about plans
21 if (currentShop.plan) {
22 return props.children;
23 } else {
24 // the merchant has not paid for the application and should be denied access, show them the plan selection interface instead of the app
25 return (
26 <Page>
27 <Layout>
28 <Banner tone="warning">You must select a plan to continue using this application</Banner>
29 <PlanSelector />
30 </Layout>
31 </Page>
32 );
33 }
34};
1import { Layout, Page, Spinner, Banner } from "@shopify/polaris";
2import type { ReactNode } from "react";
3import { api } from "../api";
4import { useFindOne } from "@gadgetinc/react";
5
6// TODO - Implement a PlanSelector component to address your plan management requirements
7import { PlanSelector } from "./PlanSelector";
8
9export const SubscriptionWrapper = (props: { shopId: string; children: ReactNode }) => {
10 const [{ fetching, data: currentShop }] = useFindOne(api.shopifyShop, props.shopId);
11 // if we're loading the current shop data, show a spinner
12 if (fetching || !currentShop) {
13 return (
14 <Page>
15 <Spinner />
16 </Page>
17 );
18 }
19
20 // if the shop has selected a plan, render the app and don't bug the merchant about plans
21 if (currentShop.plan) {
22 return props.children;
23 } else {
24 // the merchant has not paid for the application and should be denied access, show them the plan selection interface instead of the app
25 return (
26 <Page>
27 <Layout>
28 <Banner tone="warning">You must select a plan to continue using this application</Banner>
29 <PlanSelector />
30 </Layout>
31 </Page>
32 );
33 }
34};

By default, Gadget will continue to receive webhooks and run syncs for any shop with the app installed. This will keep data for the shop up to date and keep any free functionality of your application working as usual. But, it can cost you money or allow merchants to use your app without paying, so it may be necessary to disable this functionality for merchants who don't have access. If you want to disable webhook processing or syncing, you'll need to configure the corresponding Shopify model's actions or the Shopify Sync model actions to prevent processing.

One-time charges 

Shopify allows applications to have one-time fees that don't automatically subscribe the merchant to anything. For example, you could charge a merchant $10 upfront to use your app forever, $10 to process 1000 orders, or $100 for an extra theme customization. One-time fees like this are created one at a time by making calls to the Shopify API, allowing merchants to pay as they go, which they sometimes prefer.

Calls should generally be made to Shopify's GraphQL API to create usage-based charges infrequently because the merchant must confirm each charge. It'd be a bad user experience for the merchant to have to confirm a $0.10 charge for each order they process, so instead, Gadget recommends recurring billing or selling chunks of usage, like $10 for processing 1000 orders. Your application must then track how often it performs the processing and calculate how much usage remains.

One-time charges are implemented using the AppPurchaseOneTime object in the Shopify API. One-time charges can be created with the appPurchaseOneTimeCreate Shopify GraphQL mutation using the connections.shopify object present within actions and HTTP routes.

For example, if we're building an application that charges one small fee upfront, we need to do two things to charge the merchant:

  1. Add a url field to the Shopify Shop object to store the confirmation URL to pass to the merchant
  2. Add code to the onSuccess function on the Install action of the Shopify Shop model to create the charge:
api/models/shopifyShop/actions/install.js
JavaScript
1export const run: ActionRun = async ({ api, record, connections, logger }) => {
2 // get an instance of the shopify-api-node API client for this shop
3 const shopify = connections.shopify.current;
4
5 // make an API call to Shopify to create a charge object
6 const result = await shopify.graphql(`
7 mutation {
8 appPurchaseOneTimeCreate(
9 name: "Basic charge"
10 price: { amount: 100.00, currencyCode: USD }
11 returnUrl: "https://example-app.gadget.app/finish-payment?shop_id=${connections.shopify.currentShopId}"
12 ) {
13 userErrors {
14 field
15 message
16 }
17 confirmationUrl
18 appPurchaseOneTime {
19 id
20 }
21 }
22 }
23`);
24
25 const { confirmationUrl, appPurchaseOneTime } = result.appPurchaseOneTimeCreate;
26
27 // store the `result.confirmationUrl` that the merchant needs to visit
28 await api.internal.shopifyShop.update(record.id, { confirmationUrl });
29
30 logger.info(
31 { appPurchaseOneTimeId: appPurchaseOneTime.id },
32 "created one time app purchase"
33 );
34};
1export const run: ActionRun = async ({ api, record, connections, logger }) => {
2 // get an instance of the shopify-api-node API client for this shop
3 const shopify = connections.shopify.current;
4
5 // make an API call to Shopify to create a charge object
6 const result = await shopify.graphql(`
7 mutation {
8 appPurchaseOneTimeCreate(
9 name: "Basic charge"
10 price: { amount: 100.00, currencyCode: USD }
11 returnUrl: "https://example-app.gadget.app/finish-payment?shop_id=${connections.shopify.currentShopId}"
12 ) {
13 userErrors {
14 field
15 message
16 }
17 confirmationUrl
18 appPurchaseOneTime {
19 id
20 }
21 }
22 }
23`);
24
25 const { confirmationUrl, appPurchaseOneTime } = result.appPurchaseOneTimeCreate;
26
27 // store the `result.confirmationUrl` that the merchant needs to visit
28 await api.internal.shopifyShop.update(record.id, { confirmationUrl });
29
30 logger.info(
31 { appPurchaseOneTimeId: appPurchaseOneTime.id },
32 "created one time app purchase"
33 );
34};

On the frontend, we can access the shop's confirmationUrl property and redirect the merchant to this URL to have them confirm the charge.

JavaScript
1import { useNavigate } from "react-router";
2
3const navigate = useNavigate();
4const shop = await api.shopifyShop.findOne(someShopId);
5
6navigate(shop.confirmationUrl);
1import { useNavigate } from "react-router";
2
3const navigate = useNavigate();
4const shop = await api.shopifyShop.findOne(someShopId);
5
6navigate(shop.confirmationUrl);

If you're using React, you could run this Action with the @gadgetinc/react React hooks package in a React component:

React
1import { useNavigate } from "react-router";
2import { useFindOne } from "@gadgetinc/react";
3import { api } from "../api";
4
5export const RedirectToConfirmationURL = (props: { theShopId: string }) => {
6 const [{ fetching, error, data }] = useFindOne(api.shopifyShop, props.theShopId);
7 const navigate = useNavigate();
8
9 if (!data) {
10 return;
11 }
12
13 if (data.confirmationUrl) {
14 navigate(data.confirmationUrl);
15 }
16};
1import { useNavigate } from "react-router";
2import { useFindOne } from "@gadgetinc/react";
3import { api } from "../api";
4
5export const RedirectToConfirmationURL = (props: { theShopId: string }) => {
6 const [{ fetching, error, data }] = useFindOne(api.shopifyShop, props.theShopId);
7 const navigate = useNavigate();
8
9 if (!data) {
10 return;
11 }
12
13 if (data.confirmationUrl) {
14 navigate(data.confirmationUrl);
15 }
16};

Implementing a free trial 

Free trials are an opportunity for merchants to see the value of an application before having to pay for it. Shopify has limited native support for free trials that allow you to start a merchant on a plan where they will only be charged after a certain time period.

Free trials using Shopify's native support are registered using the same API calls as a normal recurring monthly charge. To add a free trial, add code to the Install Action of the Shopify Shop model that creates a recurring monthly charge with the trialDays property set:

api/models/shopifyShop/actions/install.js
JavaScript
1export const onSuccess: ActionOnSuccess = async ({ logger, connections }) => {
2 const shopify = connections.shopify.current;
3 if (!shopify) {
4 throw new Error("Missing Shopify connection");
5 }
6
7 const result = await shopify.graphql(`
8 mutation {
9 appSubscriptionCreate(
10 name: "Recurring Plan with 7 day trial",
11 trialDays: 7,
12 returnUrl: "https://example-app.gadget.app",
13 lineItems: [{
14 plan: {
15 appRecurringPricingDetails: {
16 price: { amount: 10.00, currencyCode: USD }
17 }
18 }
19 }]
20 ) {
21 userErrors {
22 field
23 message
24 }
25 confirmationUrl
26 appSubscription {
27 id
28 }
29 }
30 }
31`);
32
33 const { appSubscription } = result.appSubscriptionCreate;
34 logger.info(
35 { appSubscriptionId: result.appSubscription.id },
36 "created app subscription"
37 );
38};
1export const onSuccess: ActionOnSuccess = async ({ logger, connections }) => {
2 const shopify = connections.shopify.current;
3 if (!shopify) {
4 throw new Error("Missing Shopify connection");
5 }
6
7 const result = await shopify.graphql(`
8 mutation {
9 appSubscriptionCreate(
10 name: "Recurring Plan with 7 day trial",
11 trialDays: 7,
12 returnUrl: "https://example-app.gadget.app",
13 lineItems: [{
14 plan: {
15 appRecurringPricingDetails: {
16 price: { amount: 10.00, currencyCode: USD }
17 }
18 }
19 }]
20 ) {
21 userErrors {
22 field
23 message
24 }
25 confirmationUrl
26 appSubscription {
27 id
28 }
29 }
30 }
31`);
32
33 const { appSubscription } = result.appSubscriptionCreate;
34 logger.info(
35 { appSubscriptionId: result.appSubscription.id },
36 "created app subscription"
37 );
38};

See Shopify's docs on free trials

Advanced free trials 

While Shopify has native free trial support built in, it doesn't support the following commonly required features:

  • reminders to the merchant to pay for the app during the trial
  • tracking for which merchants have already used a free trial

Gadget allows you to customize your application's free trial experience to try and drive more merchant conversions.

If you want to show developers how long is left in their free trial, you need to track when a free trial started. Gadget recommends adding a new date / time Trial Started At field to your Shopify Shop model to track when each merchant started their trial. In the Install Action for the Shopify Shop model, you can populate this field so you can later check against it:

api/models/shopifyShop/actions/install.js
JavaScript
1export const onSuccess: ActionOnSuccess = async ({ api, record }) => {
2 if (!record.trialStartedAt) {
3 // record the current time as the trial start date
4 await api.internal.shopifyShop.update(record.id, { trialStartedAt: new Date() });
5 }
6};
1export const onSuccess: ActionOnSuccess = async ({ api, record }) => {
2 if (!record.trialStartedAt) {
3 // record the current time as the trial start date
4 await api.internal.shopifyShop.update(record.id, { trialStartedAt: new Date() });
5 }
6};

The above code example will not restart a shop's trial if they install your application a second time, which prevents nefarious merchants from uninstalling and reinstalling your application repeatedly to avoid having to pay.

Second, during a free trial, it is important to reveal to the merchant that they are, in fact, on a free trial, and they'll be charged once it elapses. This is most often done within the merchant-facing frontend of your application with a banner or similar notification, which gives the merchant more information or guides them into a plan selection interface. Once the merchant has selected a plan, the notification should be hidden. This can be done with a React component which always fetches the current shop and inspects the plan state:

React
1import { Banner } from "@shopify/polaris";
2import { useFindOne } from "@gadgetinc/react";
3import { api } from "../api";
4
5const trialLengthInDays = 7;
6
7export const PlanSelectionBanner = (props: { shopId: string }) => {
8 const [{ fetching, data: currentShop }] = useFindOne(api.shopifyShop, props.shopId);
9 if (fetching || !currentShop || !currentShop.plan || !currentShop.trialStartedAt) {
10 return null;
11 }
12
13 const startedDate = currentShop.trialStartedAt;
14 const daysUntilTrialOver = getDaysUntilTimestamp(startedDate, trialLengthInDays);
15
16 return (
17 <Banner>
18 <p>
19 You have {daysUntilTrialOver} many day(s) left on your free trial. Please <a href="/select-a-plan">select a plan</a> to keep using
20 this great app!
21 </p>
22 </Banner>
23 );
24};
25
26export const getDaysUntilTimestamp = (date: Date, daysOffset: number = 0): number => {
27 const now = Date.now();
28 const timestamp = date.getTime();
29 const diffInMs = timestamp - now;
30 const diffInDays = Math.ceil(diffInMs / (1000 * 60 * 60 * 24)) + daysOffset;
31 return diffInDays;
32};
1import { Banner } from "@shopify/polaris";
2import { useFindOne } from "@gadgetinc/react";
3import { api } from "../api";
4
5const trialLengthInDays = 7;
6
7export const PlanSelectionBanner = (props: { shopId: string }) => {
8 const [{ fetching, data: currentShop }] = useFindOne(api.shopifyShop, props.shopId);
9 if (fetching || !currentShop || !currentShop.plan || !currentShop.trialStartedAt) {
10 return null;
11 }
12
13 const startedDate = currentShop.trialStartedAt;
14 const daysUntilTrialOver = getDaysUntilTimestamp(startedDate, trialLengthInDays);
15
16 return (
17 <Banner>
18 <p>
19 You have {daysUntilTrialOver} many day(s) left on your free trial. Please <a href="/select-a-plan">select a plan</a> to keep using
20 this great app!
21 </p>
22 </Banner>
23 );
24};
25
26export const getDaysUntilTimestamp = (date: Date, daysOffset: number = 0): number => {
27 const now = Date.now();
28 const timestamp = date.getTime();
29 const diffInMs = timestamp - now;
30 const diffInDays = Math.ceil(diffInMs / (1000 * 60 * 60 * 24)) + daysOffset;
31 return diffInDays;
32};

With this tracking of a merchant's trial start date in place, you can implement a plan selection screen and an Action to power the actual plan selection. See Subscribing to a recurring plan for details on implementing plan selection.

Finally, with a trial's duration tracked and plan selection implemented, Gadget suggests denying access to your application to merchants whose trials have expired without selecting a plan. You can disable backend logic using the details in Preventing access without payment, and you can implement frontend logic to force plan selection in your merchant-facing frontend.

Using React, this can be done using a wrapper component around your app, which checks the plan status for every page the merchant tries to access:

web/components/SubscriptionWrapper.jsx
React
1import { Banner, CalloutCard, Layout, Page, Spinner } from "@shopify/polaris";
2import type { ReactNode } from "react";
3import { useFindOne } from "@gadgetinc/react";
4import { api } from "../api";
5
6// TODO - Implement a PlanSelector component to address your plan management requirements
7import { PlanSelector } from "./PlanSelector";
8
9const trialLengthInDays = 7;
10
11// this would replace the PlanSelectionBanner component above
12export const SubscriptionWrapper = (props: { shopId: string; children: ReactNode }) => {
13 const [{ fetching, data: currentShop }] = useFindOne(api.shopifyShop, props.shopId);
14 // if we're loading the current shop data, show a spinner
15 if (!currentShop || fetching) {
16 return (
17 <Page>
18 <Spinner />
19 </Page>
20 );
21 }
22
23 // if the shop has selected a plan, render the app and don't bug the merchant about plans
24 if (currentShop.plan) return props.children;
25
26 // Do not show plan if there is no trial start date
27 if (!currentShop.trialStartedAt) return props.children;
28
29 const daysUntilTrialOver = getDaysUntilTimestamp(currentShop.trialStartedAt, trialLengthInDays);
30 if (daysUntilTrialOver > 0) {
31 // the merchant is on a free trial, show the app and a banner encouraging them to select a plan
32 return (
33 <>
34 {props.children}
35 <Banner>
36 You have {daysUntilTrialOver} many day(s) left on your free trial. Please <a href="#">select a plan</a> to keep using this great
37 app!
38 </Banner>
39 </>
40 );
41 } else {
42 // the merchant's trial has expired, show them the plan selection interface, don't show them the app
43 return (
44 <Page>
45 <Banner tone="critical">Your trial has expired, please select a plan to continue using the application</Banner>
46 <PlanSelector />
47 </Page>
48 );
49 }
50};
51
52export const getDaysUntilTimestamp = (date: Date, daysOffset: number = 0): number => {
53 const now = Date.now();
54 const timestamp = date.getTime();
55 const diffInMs = timestamp - now;
56 const diffInDays = Math.ceil(diffInMs / (1000 * 60 * 60 * 24)) + daysOffset;
57 return diffInDays;
58};
1import { Banner, CalloutCard, Layout, Page, Spinner } from "@shopify/polaris";
2import type { ReactNode } from "react";
3import { useFindOne } from "@gadgetinc/react";
4import { api } from "../api";
5
6// TODO - Implement a PlanSelector component to address your plan management requirements
7import { PlanSelector } from "./PlanSelector";
8
9const trialLengthInDays = 7;
10
11// this would replace the PlanSelectionBanner component above
12export const SubscriptionWrapper = (props: { shopId: string; children: ReactNode }) => {
13 const [{ fetching, data: currentShop }] = useFindOne(api.shopifyShop, props.shopId);
14 // if we're loading the current shop data, show a spinner
15 if (!currentShop || fetching) {
16 return (
17 <Page>
18 <Spinner />
19 </Page>
20 );
21 }
22
23 // if the shop has selected a plan, render the app and don't bug the merchant about plans
24 if (currentShop.plan) return props.children;
25
26 // Do not show plan if there is no trial start date
27 if (!currentShop.trialStartedAt) return props.children;
28
29 const daysUntilTrialOver = getDaysUntilTimestamp(currentShop.trialStartedAt, trialLengthInDays);
30 if (daysUntilTrialOver > 0) {
31 // the merchant is on a free trial, show the app and a banner encouraging them to select a plan
32 return (
33 <>
34 {props.children}
35 <Banner>
36 You have {daysUntilTrialOver} many day(s) left on your free trial. Please <a href="#">select a plan</a> to keep using this great
37 app!
38 </Banner>
39 </>
40 );
41 } else {
42 // the merchant's trial has expired, show them the plan selection interface, don't show them the app
43 return (
44 <Page>
45 <Banner tone="critical">Your trial has expired, please select a plan to continue using the application</Banner>
46 <PlanSelector />
47 </Page>
48 );
49 }
50};
51
52export const getDaysUntilTimestamp = (date: Date, daysOffset: number = 0): number => {
53 const now = Date.now();
54 const timestamp = date.getTime();
55 const diffInMs = timestamp - now;
56 const diffInDays = Math.ceil(diffInMs / (1000 * 60 * 60 * 24)) + daysOffset;
57 return diffInDays;
58};

Crediting merchants 

Occasionally, application developers will want to give a credit to individual merchants. They may offer a refund when a merchant contacts them to cancel or discount the product for a potentially high-value customer. Credits are implemented with the AppCredit object created using the Shopify API.

Generally, credits are given to merchants manually by administrators, so there's no merchant-facing UI to build. Sometimes it's easiest to create credits manually using handcrafted API requests to Shopify's API, but if you'd like to build an easier-to-use interface for crediting, Gadget recommends adding a Credit Action on the Shopify Shop model. You can then add code to create an AppCredit object for some amount.

For example, we could add this code to a new Credit Action on the Shopify Shop model:

api/models/shopifyShop/actions/credit.js
JavaScript
1export const onSuccess: ActionOnSuccess = async ({ connections, logger }) => {
2 // get an instance of the shopify-api-node API client for this shop
3 const shopify = connections.shopify.current;
4 if (!shopify) {
5 throw new Error("Missing Shopify connection");
6 }
7
8 // make an API call to Shopify to create a charge object
9 const result = await shopify.graphql(
10 `
11 mutation CreateCredit($amount: Decimal!) {
12 appCreditCreate(
13 description: "application credit"
14 amount: {
15 amount: $amount,
16 currencyCode: USD
17 }
18 test: true
19 ) {
20 userErrors {
21 field
22 message
23 }
24 appCredit {
25 id
26 }
27 }
28 }
29 `,
30 { amount: params.amount }
31 );
32
33 const { appCredit } = result.appCreditCreate;
34
35 logger.info({ amount: params.amount, creditID: appCredit.id }, "credited shop");
36};
37
38// make this effect take an amount parameter for the amount to credit
39export const params = {
40 amount: { type: "number" },
41};
1export const onSuccess: ActionOnSuccess = async ({ connections, logger }) => {
2 // get an instance of the shopify-api-node API client for this shop
3 const shopify = connections.shopify.current;
4 if (!shopify) {
5 throw new Error("Missing Shopify connection");
6 }
7
8 // make an API call to Shopify to create a charge object
9 const result = await shopify.graphql(
10 `
11 mutation CreateCredit($amount: Decimal!) {
12 appCreditCreate(
13 description: "application credit"
14 amount: {
15 amount: $amount,
16 currencyCode: USD
17 }
18 test: true
19 ) {
20 userErrors {
21 field
22 message
23 }
24 appCredit {
25 id
26 }
27 }
28 }
29 `,
30 { amount: params.amount }
31 );
32
33 const { appCredit } = result.appCreditCreate;
34
35 logger.info({ amount: params.amount, creditID: appCredit.id }, "credited shop");
36};
37
38// make this effect take an amount parameter for the amount to credit
39export const params = {
40 amount: { type: "number" },
41};

We could then invoke this action using the Gadget GraphQL API in an internal, staff-only frontend:

JavaScript
await api.shopifyShop.credit(someShopId, { amount: 10 });
await api.shopifyShop.credit(someShopId, { amount: 10 });

Read more about creating credits in Shopify's docs.

Was this page helpful?