Shopify

What does the Shopify connection provide?

  • All models in Shopify's REST API have their schemas mirrored as models in Gadget. These models can be extended in Gadget with additional fields, though these fields will not sync back with Shopify.
  • All Shopify models sync data from Shopify using webhooks and a daily background sync, as per Shopify's recommended best practices. You can also manually sync your app to Shopify at any time with a manual sync.
  • An authenticated client for you to use in your code effects.

In addition, Gadget converts the webhook payloads from Shopify into records, so you can easily read and manipulate Shopify data in your app.

Currently, Gadget uses Shopify's 2022-01 API version for new connections.

Setting up the Shopify connection

Click Connections on the sidebar to start setting up your Shopify Connection.

You have two options for creating a custom app in Shopify - via the Shopify Partners Dashboard or directly in the merchant shop. Public apps can only be created in the Partners Dashboard.

Shopify Partners connection

A view of the connections page and info needed to start setting up a connection

You will need to use the App URL and Allowed redirection URL(s) values to set up your app in the Partners Dashboard.

Shopify app setup screen showing the required Gadget values

Copy the API Key and API Secret from the Partners Dashboard after you have created a new Shopify app, and click Connect.

Now you can choose what scopes you need for your application:

Page for selecting what Shopify scopes you want to grant your app access to

When selecting an access scope, you'll be presented with any relevant models for that scope. For example, if you choose the read_products scope, you'll be able to select models related to products and collections. These are models that Gadget will create in your app, and will be used by the Shopify connection to store incoming data.

Once you've selected your access scopes and models you can click Confirm. This will securely store your app credentials in Gadget until a store is associated with the connection.

The Connections page now has details about your connection. You can also set a Custom Redirect URL that specifies the URL that Gadget will redirect the user to after a successful Shopify OAuth.

A view of the Connections page after successfully setting up a Shopify Connection

You can use two Shopify applications, one for production and one for development. Each may have a custom redirect URL. The Custom Redirect URL is required for embedded applications. Use the Add Custom App button to connect to additional Shopify apps.

Manage installs

You can click Manage Installs to view the shops that have installed your application. This page also allows you to manually Sync data per store or view Sync History.

A view of the Manage Installs page with two successfully installed stores

You can click the Sync button to sync a single store. Expanding the options for a single store allows you to view the Sync History or to Register Webhooks.

'View Sync History' and 'Register Webhooks' drop down options for a single store on the Manage Installs page

You can also perform operations on multiple stores at once by selecting multiple stores and then clicking Sync or Register Webhooks buttons.

Displays available bulk operations on the on the Manage Installs page, 'Sync' and 'Register Webhooks'

The Manage Installs page also lets you know if you are missing access scopes on your store or if you are missing topics/namespaces for your registered webhooks.

To fix missing access scopes, the merchant who installed the application needs to grant access to the scope in the Shopify dashboard.

Warning message on a store missing 2 scopes and a pop over detailing the missing scopes

Selecting Register Webhooks from the store options button or clicking Register Webhooks with multiple stores selected should fix missing webhook topics in most cases. If you are editing a connection that includes new models you need to grant additional scopes before the new webhooks can be registered. The Register Webhooks button will also not handle missing checkouts_create and checkouts_update webhooks at this time.

Warning message on a store missing multiple webhook topics and a pop over listing the topics

There are two ways to associate a Shopify store with a custom app: 1. Through a merchant install link , or 2. via the "Test on development store" link in the application view on your Partners Dashboard. Both of these will start Shopify's installation flow , where the user has to authorize your app to be able to access its data. Once authorized, your connection is now associated with that store. You should see the shop's name and domain on the connections index page, and the sync button should now be available so that you can get the shop's data into Gadget. You can repeat the above process if you need to connect to another store, which you would typically do to allow installation on both a development store and the associated merchant store.

Shopify Admin connection

You also have to option to create a custom Shopify App directly in the Shopify Admin. If this is the case, you need to set up a Shopify Admin connection in Gadget.

The Connections page displaying the Shopify Admin option selected, and the required fields (Shop URL, API Key and Secret, Access Token)

After you have created a new app on your Shopify store's Admin page, you should select the Shopify Admin API scopes required by your new application. Once you have selected the required scopes, you need to install your app which will generate an access token required by Gadget. You need to go to the API credentials tab in Shopify's Admin to install the app.

Screenshot of the Shopify Admin app API scope selection page, with the read/write product scope selected

After successfully installing, you have the chance to copy the access token. This token is required by Gadget to establish a connection to your custom Admin app! Copy this Admin API access token and paste it on your Gadget Connections page under Access Token. You can also copy over the API Key and API Secret, as well as your Shop Domain. Then click Connect.

Screenshot of the Shopify Admin app API credentials page, with an access token, and api key available

If you need to generate another access token, you can uninstall and then reinstall your custom application on your store!

Now you can select the Shopify models and data you want to import into your Gadget app. Gadget lists all of the Shopify scopes. Currently, you need to select the same scopes that have been selected on the Shopify Admin page. Each scope can be expanded to view the data models that can be imported when permissions for this scope have been granted in the Shopify Admin. Select the models you need to import and click Confirm to create your connection.

The Connect with Shopify Admin page displaying the available Shopify scopes. One scope is expanded to show the Shopify models availble to import

Ensure you have granted API access to selected scopes to avoid webhook failures.

Gadget will import the Shopify models automatically. If you have the correct scopes granted in the Shopify Admin, you will be able to click the Sync button to pull data from your Shopify store into your Gadget app.

The updated Connection page with a successful connection

Edit connections

You can also edit your connections using the Edit button on the Connections page. This allows for changes to be made to the Shopify scopes and models that your application can access and import.

How does the Shopify connection work?

All of the Shopify fields are mapped to a corresponding field in your Gadget model. You can identify these fields by looking at the schema for any of your app's Shopify models and finding any field with the Shopify icon.

A view of the schema editor for the Shopify product model

When a record in Shopify is changed, Shopify sends a webhook to your Gadget application, which triggers the appropriate model action. A model's create webhook will fire the create model action, update will fire update, and delete will fire delete. All of the incoming fields from the webhook are mapped and stored in their corresponding field in Gadget.

Syncs work much like webhooks, in that they will create or update records in Gadget. However, Gadget currently does not delete any models that exist in Gadget but no longer exist in Shopify.

Delete handling

Any deletion webhooks will call the delete action on the model, along with running the delete action for all Shopify records associated with that model. For example, when a product is deleted, we delete all of its options, images, and variants. All other webhooks will call either the create or update action, based on whether or not the record currently exists in Gadget.

Modeling

Once set up, all of the models based on your selected scopes are now part of your app's GraphQL API, and you can query them like any other model in your app. You can change Shopify fields however you please, except for their type. You can also add your own fields to these models.

If you call actions from your GraphQL API, Gadget currently does not automatically keep the remote record in sync. For example, calling shopifyProductUpdate to set the title of your Shopify product will update the title field in Gadget, but not on Shopify.

You can also add behavior to any of these models. You can even add your own states and actions to the model's state chart. You can delete the create, update, or delete actions, but this will prevent the connection from being able to perform those actions in response to webhooks/syncs.

Gadget doesn't prevent you from deleting models, but doing so may prevent data from being updated. For example, if you have a product and product variant model, deleting the product model will mean that you no longer get updates to variant models. Deleting the product variant model is fine, and will not prevent products from being synced.

In addition to the models available to you based on your selected scopes, the Shopify connection creates three additional models: Shopify Shop, Shopify Sync, and Shopify GDPR Request.

Shopify Shop

The Shopify Shop model is a representation of a Shopify shop connected to Gadget via the Shopify connection. It stores information about the connected shop, and a record is created when a shop is connected for the first time. You can create code effects on the creation of a Shopify Shop record that will then execute your logic on app installation.

Shopify Sync

The Shopify Sync model represents the data sync between Shopify and Gadget for any given connected Shopify shop. Like the Shopify Shop model, you can create code effects on the creation of a sync record, which will execute whenever a sync is initiated between Shopify and Gadget.

Shopify GDPR Request

The Shopify GDPR Request model represents requests from Shopify's mandatory webhooks in compliance with General Data Protection Regulation (GDPR). You can add custom code effects to the Shopify GDPR Request model's create action to handle the request as needed, ensuring your app is GDPR compliant. To enable GDPR webhook requests from Shopify, go to the Apps page on the Shopify Partner Dashboard, select your application, click App setup, and paste the following snippet in the GDPR mandatory webhooks section. https://app.gadget.dev/connections/webhooks/shopify/undefined

Adding behavior

Shopify models can be given business logic just like any other model in Gadget using the behaviour state chart. Most often, developers add Run Code effects to the Success Effect list for a create, update, or delete actions a Shopify model. These effects can make calls to other APIs, create or change data in the Gadget app, or make calls back to Shopify. Within effects, you have access to the current record that is being manipulated, the incoming params from the webhook, the api object for fetching or changing other data in your app, and the connections object for making API calls back to Shopify.

Note: Gadget's automatic data sync replicates changes made in Shopify to your Gadget app, but not the other way around. If you want to update Shopify data, you must make explicit API calls to the Shopify API to do so.

For example, let's say we wanted to build an automatic product tagger for Shopify products. When a product is changed in Shopify, our Gadget app should parse the product body, extract some new tags, and send them back to Shopify so customers can search for the product on the storefront easily. To do this, we can add a new Run Code effect to the Shopify Product model's update action that does this in code:

shopifyProduct/update/apply-tags.js
JavaScript
1const { extractKeywords } = require("easy-keywords");
2
3module.exports = async ({ api, record, params, logger, connections }) => {
4 if (record.changed("body")) {
5 const newTags = (await extractKeywords(record.body)).slice(20);
6 const allowedTags = (await api.allowedTag.findMany()).map(
7 (record) => record.tag
8 );
9 logger.info({ newTags }, "saving new tags");
10 const shopify = await connections.shopify.forShopDomain(
11 "some-great-shop.myshopify.com"
12 );
13 await shopify.product.update(record.id, { tags: newTags.join(", ") });
14 }
15};

The above code extracts tags from the product's body field using a module from npm, and then gets an authenticated API client for working with a specific Shopify shop's API, and then updates the product by making an API call back to shopify using that client.

Accessing the Shopify API

Gadget provides a helper for quickly accessing a client API object for communicating with Shopify. Gadget uses the shopify-api-node library as it is battle tested, has good coverage of the Shopify API, and has support for automatic retries to avoid the Shopify API rate limits.

To access a Shopify API client, use the connections.shopify object in your effect code. connections.shopify is a helper object with some handy functions for accessing already-created API Client objects:

  • connections.shopify.current returns a Shopify API client for the current shop if there is one in context. For Public Shopify apps making requests from an embedded app, Gadget is able to track the shop making the request and populate this value.
  • connections.shopify.forShopId allows creating a Shopify client instance for a specific shop ID
  • connections.shopify.forShopDomain allows creating a Shopify client instance for a specific myshopify domain.

More info on the connections.shopify helper object can be found in the Code Reference for your application.

For example, we can use connections.shopify to access a Shopify client for a given store, and then make a call to the Products REST API in Shopify to create a new product record:

TypeScript
1module.exports = async ({ connections }) => {
2 const shopifyClient = await connections.shopify.forShopDomain(
3 "the-store.myshopify.com"
4 );
5 await shopifyClient.product.create({
6 title: "New Product",
7 body_html: "This is the latest product on The Store",
8 tags: ["product", "new"],
9 });
10};

If you'd prefer to use another client, the options attribute of the provided client has all the necessary details to construct one:

TypeScript
1import MyShopifyClient from "my-shopify-client";
2module.exports = async ({ connections }) => {
3 const shopifyClient = await connections.shopify.forShopDomain(
4 "the-store.myshopify.com"
5 );
6 const myClient = new MyShopifyClient({
7 myshopifyDomain: shopifyClient.options.shopName,
8 accessToken: shopifyClient.options.accessToken,
9 });
10 // ...
11};

When to make API calls to Shopify in Code Effects

For reading data within your effects, API calls can be placed anywhere without issue. That said, reaching out to Shopify can be expensive and can exhaust your Shopify API rate limit, so it is generally preferable to read data from your Gadget database instead of right from Shopify. Your Gadget database is a performant, nearby copy of the Shopify data expressly designed to power your business logic and works well for this, but if you need the most up-to-date data possible, making an API call to Shopify works too.

For writing data within your effects, API calls should generally be placed within Success Effects. Success effects run after the run effects have all completed without errors, which gives more confidence that your business logic has worked and saved changes to the database. This makes it quite a bit less likely that your system and the external system will get out of sync. Gadget, by default, will execute the run effects of your app's actions inside a database transaction. When the run effects fail for these actions, Gadget undoes (rolls back) any changes made to your app's database during the action. Gadget cannot roll back changes made to external systems, so if you have made API calls within a failing run effect list, the side effects of those API calls will remain. Care should be taken to make API calls to external systems in the right place.

Managing Shopify API rate limits

Shopify has restrictive rate limits, limiting apps to making 2 requests per second to any given shop. To avoid errors from hitting this rate limit, Gadget recommends two strategies:

  1. Use the shopify-api-node object returned by Gadget's connections.shopify helper, which is preconfigured to retry when encountering rate limit errors. shopify-api-node will retry a request up to 12 times, respecting Shopify's Retry-After header. If you need to make a write request to Shopify or a read of the most up to date data possible, this is the best option.
  2. Read data from your Gadget database where possible. Gadget's Shopify Connection syncs data from Shopify to your Gadget database so you can read quickly and without rate limits.

Read more about Shopify's rate limits here: https://shopify.dev/api/usage/rate-limits

Current shop tenancy in Code Effects

As the front-end is making queries and performing mutations on the data in your Gadget application, it is important to make sure that cross-talk between different shops is not allowed. This is especially relevant for public Shopify apps.

The context of the current shop is determined by the user and session making the request, or, if the Effect is triggered by a webhook or sync, the current record that is being processed.

You can use connections.shopify.current to gain access to the current shop's information. It is preferred to use connections.shopify.current in Code Effects to prevent any cross-talk between shops. You can then use the shopify-api-node API off of the current shop to interact with the current Shopify store.

shopifyProduct/update/update-product.js
JavaScript
1module.exports = async ({ api, record, params, logger, connections }) => {
2 if (connections.shopify.current) {
3 await connections.shopify.current.product.update(record.id, {
4 tags: "foo, bar, baz",
5 });
6 }
7};

It can also be useful to understand how filtered model permissions work in Gadget as they determine what default filters, such as shopId in the case of a public app, are applied to GraphQL queries and mutations made through your application's API.

Webhook infinite loop handling

It is common for Gadget code effects running in response to a webhook to make calls to Shopify which then trigger the same webhook a second time. In the example above, we're processing an update action on a product record, and within it, we update the same product record again using the Shopify API. This triggers a second product/update webhook, which will dispatch the same Gadget action and run the effects again. Without care, this can cause infinite webhook loops, which chews through your Shopify API rate limit and Gadget resources.

To avoid webhook infinite loops, you must ensure that your code doesn't re-process the changes it makes and re-trigger webhooks forever. The best strategy for detecting this is to only trigger your business logic if the fields you care about have changed on the record, instead of always doing it when any field changes. The record object in the effect parameters is a GadgetRecord object which reports which fields have been changed by the incoming webhook payload, and which have not.

In the example above, we only need to extract new tags for the product when the product's body field has changed. If anything else changes, we don't need to change the tags, as they purely come from the body, so we wrap our business logic in a check for if (record.changed("body")). This way, when we make the API call to update the product's tags in Shopify, Gadget will receive this webhook and dispatch the update action, but our code won't run twice, as record.changed('body') will be false, as we only updated the record's tags.

More details on the GadgetRecord changes API can be found here.

Syncing

The syncing system for the Shopify connection works by leveraging Actions and Effects. Each time Gadget goes to sync the connected shop, Gadget creates a new Shopify Sync record and dispatches the Run and either the Complete or Error actions based on the outcome of the sync. There are three types of syncs for the Shopify connection: API-triggered, Manual, and Daily.

Daily syncs

The Shopify connection will automatically fetch all recent data in the background daily. This ensures we have all of the shop's data, in the case of a missing webhook, or for resources that don't have webhooks (such as the Online Store content). This sync only fetches data from the past day, so if the sync fails or has scheduling issues, there's a possibility you may not have the most up-to-date data in Gadget. If this does occur, check the Shopify Sync data viewer or logs via the Sync History to identify any errors.

API-triggered Syncs

An API-triggered sync can be initiated via the GraphQL API or api in code effects. For example, it may be necessary to run an initial sync upon installation. A success effect can be added to the Shopify Shop model's create action with the following code effect:

shopifyShop/create/initial-sync.js
JavaScript
1module.exports = async ({ api, record, params, logger, connections }) => {
2 await api.shopifySync.run({
3 shopifySync: {
4 domain: record.domain,
5 shop: {
6 _link: record.id,
7 },
8 },
9 });
10};

Sync by date-time

You can optionally pass a syncSince to indicate the earliest date-time you wish to sync from; without this parameter, the API-triggered sync will be a full data sync.

shopifyProduct/update/run-sync.js
JavaScript
1module.exports = async ({ api, record, params, logger, connections }) => {
2 if (record.shopId) {
3 const shop = await api.shopifyShops.findOne(record.shopId);
4 const syncSince = new Date();
5 const millisecondsPerDay = 1000 * 60 * 60 * 24;
6 // sync from 5 days ago
7 syncSince.setTime(syncSince.getTime() - 5 * millisecondsPerDay);
8 await api.shopifySync.run({
9 shopifySync: {
10 domain: shop.domain,
11 // optional parameter
12 syncSince,
13 shop: {
14 _link: record.shopId,
15 },
16 },
17 });
18 }
19};

Sync by model

You may also optionally pass a models parameter to the runSync call to specify which particular Shopify models to sync; this should be an array of model API identifiers. If the models parameter is either omitted or is an empty array, all Shopify models will be synced.

shopifyProduct/update/run-sync.js
JavaScript
1module.exports = async ({ api, record, params, logger, connections }) => {
2 if (record.shopId) {
3 const shop = await api.shopifyShops.findOne(record.shopId);
4 await api.shopifySync.run({
5 shopifySync: {
6 domain: shop.domain,
7 shop: {
8 _link: record.shopId,
9 },
10 // optional parameter
11 models: ["shopifyProduct"],
12 },
13 });
14 }
15};

Manual syncs

A sync will be queued (without a syncSince set) when clicking the "Sync" button next to your Shopify connection. This will make a call to the Shopify Sync model's Run action.

The connections index page having performed a single sync successfully

This sync may take some time to complete, depending on how much data the shop has.

Sync error handling

If an error occurs during a sync, the Shopify Sync model's Error action will be called. For example, here's how you teach Gadget to alert you via SMS when a sync fails.

shopifySync/error/handle-error.js
JavaScript
1const twilio = require("../../twilio-client");
2
3module.exports = async ({ api, record, params, logger, connections }) => {
4 /*
5 params: {
6 id: string;
7 shopifySync: {
8 errorMessage: string;
9 errorDetails: string;
10 }
11 }
12 */
13
14 const errors = params.shopifySync.errorDetails.split("\n");
15
16 // send the errors individually to the logger
17 for (const error of errors) {
18 logger.error({ error }, "an error occurred syncing");
19 }
20
21 // send an SMS notification
22 await twilio.sendSMS({
23 to: "+11112223333",
24 from: "+11112223333",
25 body: `Shopify sync failed with message: ${params.shopifySync.errorMessage}`,
26 });
27};

id is the Shopify Sync id that was created
errorMessage contains a generate description of the error or errors
errorDetails contains a all error messages (if more than 1 occurred) joined by a \n

The best way to debug why a sync may have failed is to search for errors through your logs. Use the following log query to filter by sync ID (replace {syncId} with ID of the sync that has failed):

{app_logs_service=~".+", environment_id="{environmentID}"} | json | connectionSyncId = "{syncID}"

Metafields

Shopify metafields allow you to extend Shopify's data model to store additional information on resources including orders, products, customers, and shop. Frequently used in custom and public app development, metafields are an important tool for building rich Shopify app experiences.

Despite their importance and popularity, working with metafields can be challenging for developers. The data is stored in a separate resource on Shopify's end (the “metafield object”), whereas you almost certainly want the data right on the resource you are extending with that metafield. Additionally, Shopify's API rate limits make this additional resource hopping even more cumbersome to navigate.

To simplify this experience, Gadget offers developers a fast way to sync, query and mutate Shopify metafields in their app's backend.

Storing metafield data in Gadget

Gadget allows you to store metafield data directly on any of the following Shopify resources: articles, blogs, collections, customers, orders, pages, products, and shop.

To do this, you must first copy the desired resource to your Gadget backend. Go to the Connections screen and pick the desired scopes and models, e.g. the read_products scope and the Product model, and connect to a store.

Once you have the desired model in your Gadget backend, click on “Add Field” to decorate the model with the additional metafield values. Name your field and select an appropriate type for the incoming data. To start the metafield import, you also need to click the Store data from Shopify Metafield box.

The required fields to set up a metafield on a Shopify Model

You now need to register your metafield's namespace. Enter the namespace that is used for your metafield in the Shopify Store, then click Register Namespace. Gadget will then register for webhooks on the entered namespace.

The namespace for a metafield is registered

Once you get a message stating that the namespace registrations are complete, you can enter the key for your metafield and select the correct metafield type.

Here's an example of what the form looks like when we try to teach Gadget to store a metafield capturing each product variant's weight, under the namespace "gadget_app" and key "spiciness":

A completed Shopify metafield setup in Gadget

Your Gadget app is now set up to store the data directly on this model. You can go back to your Connections page and sync to pull in metafield data. Once the metafield data is synced to your Gadget app, you can query or mutate metafield values using Gadget Actions.

Querying metafields

One of the main advantages of Gadget's approach to metafields is that once you specify where the metafield values should be stored, you can query the data using Gadget's instant CRUD APIs.

You can fetch metafield values alongside the records they correspond to, in a single query. You can also run range queries directly on the metafields.

Metafield query examples

Here's an example of a range query being run on “spiciness”, a metafield stored on the Shopify Product, in order to fetch products that have a spiciness rating of greater than 5:

GraphQL
1query {
2 shopifyProducts(
3 filter: { spiciness: { greaterThan: 5 } }
4 sort: { spiciness: Ascending }
5 ) {
6 edges {
7 node {
8 title
9 id
10 spiciness
11 handle
12 }
13 }
14 }
15}

Writing to metafields

If you're looking to mutate metafield values in Gadget and sync them back to Shopify, you will need to teach Gadget about the mutation in code. You can do this in Gadget by writing code Effects and having Gadget run them when the API is triggered.

Metafield writing examples

Here's an example of a code effect added to the Update action of a Shopify Customer model that overrides the metafield data in Gadget first, then sends the information to Shopify so that the data stays in sync:

JavaScript
1/**
2 * Fetch the ids for metafields on a resource
3 */
4const fetchMetafieldIds = async ({ resource, shopifyApi, metafieldSchema, id }) => {
5 const gidResource = resource.charAt(0).toUpperCase() + resource.slice(1);
6 const gid = `gid://shopify/${gidResource}/${id}`;
7 return (
8 await shopifyApi.graphql(
9 `
10 query MetafieldIds($id: ID!) {
11 metafields: ${resource}(id: $id) {
12 ${metafieldSchema.metafields.map(
13 ({ namespace, key }) =>
14 `${namespace}_${key}: metafield(namespace: ${JSON.stringify(
15 namespace
16 )}, key: ${JSON.stringify(key)}) { id }`
17 )}
18 }
19 }
20 `,
21 {
22 id: gid,
23 }
24 )
25 ).metafields;
26};
27
28/**
29 * Get the shopify metafield configuration for all metadata fields in `model`
30 */
31const getModelMetafieldSchema = (model) => {
32 const metafields = [];
33 const privateMetafields = [];
34 for (const field of Object.values(model.fields)) {
35 const contributorConfig =
36 field.contributorConfiguration["shopify/contributor/field/metafield"];
37 if (!contributorConfig) continue;
38
39 const metafield = contributorConfig.metafield;
40 if (!metafield.namespace || !metafield.key) continue;
41
42 if (!metafield.isPrivate) {
43 metafields.push({ ...metafield, apiIdentifier: field.apiIdentifier });
44 } else {
45 privateMetafields.push({ ...metafield, apiIdentifier: field.apiIdentifier });
46 }
47 }
48
49 return { metafields, privateMetafields };
50};
51
52const buildRESTMetafieldsPayload = async ({
53 resource,
54 shopifyApi,
55 model,
56 record,
57}) => {
58 const metafieldSchema = getModelMetafieldSchema(model);
59 const metafieldIds = await fetchMetafieldIds({
60 resource,
61 shopifyApi,
62 metafieldSchema,
63 id: record.id,
64 });
65
66 // Make sure the field we're updating has a metafield and that if it has a Shopify id then include id
67 return metafieldSchema.metafields.map(
68 ({ namespace, key, type, apiIdentifier }) => {
69 const gid = metafieldIds[`${namespace}_${key}`]?.id;
70 const id = gid ? gid.match(/^gid:\/\/shopify\/[A-Za-z]+\/(\d+)$/)[1] : null;
71 return {
72 id,
73 namespace,
74 key,
75 type,
76 value: record[apiIdentifier],
77 };
78 }
79 );
80};
81
82// Once a record has been updated in Gadget, fire off an update to the Shopify API
83// to keep our meta fields in sync
84module.exports = async ({ model, record, connections }) => {
85 const shopifyApi = await connections.shopify.forShopDomain(
86 "mycoolshop.myshopify.com"
87 );
88
89 // If customer's order count is greater than 10, mark them with a VIP metafield flag
90 if (record.ordersCount > 10) {
91 // Set VIP flag to true
92 // This field will have been set up as a metafield in the Shopify Customer model
93 record.isVip = true;
94
95 // Update metafield on a resource through the REST API
96 await shopifyApi.customer.update(Number(record.id), {
97 metafields: await buildRESTMetafieldsPayload({
98 resource: "customer",
99 shopifyApi,
100 model,
101 record,
102 }),
103 });
104 }
105};

Metafield utility methods

In the above example, we implement three utility methods to support querying and writing to metafields in code effects. While these methods are not built into the client, you are free to use them in your own code effects.

getModelMetafieldSchema is used to find all fields on a Gadget model that is set up as a metafield. This method returns an object that has keys for public and private metafield definitions.

fetchMetafields is used to fetch existing metafield definitions on a Shopify shop. This is necessary to ensure that when writing to a metafield, an existing metafield namespace and key combination includes its ID, as an ID is required by Shopify to write to an existing namespace and key combination.

buildRESTMetafieldsPayload matches the record's fields with a model metafield and adds an appropriate Shopify metafield definition ID to the REST payload.

Available Models

Gadget receives webhooks and syncs data from Shopify for the following models:

Model NameRequired ScopesWebhook Topics
Blogread_contentsynced only
Articleread_contentsynced only
Commentread_contentsynced only
Themeread_themesthemes/create, themes/update, themes/delete
Assetread_themessynced only
Balance Transactionread_shopify_payments_payoutssynced only
Checkoutread_checkouts, read_orderscheckouts/create, checkouts/update
The read_checkouts scope will allow Gadget to process checkout related webhooks, but does not provide a mechanism to sync all existing checkouts.

Adding the read_orders scope, while also including the Checkout model in your application, will result in all Abandoned Checkouts that aren't handled via webhooks (failed delivery, checkouts that already exist) being imported during nightly or manual syncs.
Billing Addressread_checkouts, read_ordersSent within Checkout model
Checkout Applied Gift Cardread_checkouts, read_orderssynced only
Checkout Line Item Applied Discountread_checkouts, read_ordersSent within Checkout Line Item model
Checkout Line Itemread_checkouts, read_ordersSent within Checkout model
Checkout Shipping Rateread_checkouts, read_orderssynced only
Shipping Addressread_checkouts, read_ordersSent within Checkout model
Carrier Serviceread_shippingsynced only
Collectionread_productscollections/create, collections/update, collections/delete
Gadget uses the Collection model to represent both Custom Collections and Smart Collections from Shopify.
Productread_productsproducts/create, products/update, products/delete
Collectread_productssynced only
Product Imageread_productsSent within Product model
Product Optionread_productsSent within Product model
Product Variantread_productsSent within Product model
Countryread_shippingsynced only
Provinceread_shippingsynced only
Customerread_customerscustomers/create, customers/update, customers/delete
Customer Addressread_customersSent within Customer model
Customer Payment Methodread_customer_payment_methodscustomer_payment_methods/create, customer_payment_methods/update
Discount Coderead_discountssynced only
Disputeread_shopify_payments_disputesdisputes/create, disputes/update
Dispute data is only available for Shopify merchants using Shopify Payments.
Payoutread_shopify_payments_payoutssynced only
Draft Orderread_draft_ordersdraft_orders/create, draft_orders/update, draft_orders/delete
Draft Order Line Itemread_draft_ordersSent within Draft Order model
Orderread_ordersorders/create, orders/updated, orders/delete
Dutyread_ordersSent within Order Line Item model
Fulfillment Eventread_ordersfulfillment_events/create, fulfillment_events/delete
Fulfillmentread_ordersfulfillments/create, fulfillments/update
Fulfillment Line Itemread_ordersSent within Fulfillment model
Order Adjustmentread_ordersSent within Refund model
Order Line Itemread_ordersSent within Order model
Order Riskread_orderssynced only
Order Transactionread_ordersorder_transactions/create
Refund Dutyread_ordersSent within Refund model
Refundread_ordersrefunds/create
Refund Line Itemread_ordersSent within Refund model
Shipping Lineread_ordersSent within Order model
Tender Transactionread_orderstender_transactions/create
Eventread_content, read_products, read_price_rules, read_orderssynced only
Fulfillment Orderread_assigned_fulfillment_orders, read_merchant_managed_fulfillment_orders, read_third_party_fulfillment_orderssynced only
Fulfillment Order Line Itemread_assigned_fulfillment_orders, read_merchant_managed_fulfillment_orders, read_third_party_fulfillment_orderssynced only
Fulfillment Serviceread_fulfillmentssynced only
Inventory Itemread_inventoryinventory_items/create, inventory_items/update, inventory_items/delete
Inventory Items are synced using the REST API endpont for Inventory Levels for each Location to get a list of inventory levels, and then using the REST API endpoint to get a list of inventory items for each level.
Inventory Levelread_inventoryinventory_levels/connect, inventory_levels/update, inventory_levels/disconnect
Inventory Levels are synced using the REST API endpoint for Inventory Levels at a Location.
Locationread_locationslocations/create, locations/update, locations/delete
Pageread_contentsynced only
Price Ruleread_price_rulessynced only
Redirectread_contentsynced only
Script Tagread_script_tagssynced only
ShopAlways availableshop/update, app/uninstalled
Subscription Contractread_own_subscription_contractssubscription_contracts/create, subscription_contracts/update
Subscription Billing Attemptread_own_subscription_contractssynced only
Subscription Lineread_own_subscription_contractssynced only
Subscription Manual Discountread_own_subscription_contractssynced only
GDPR RequestAlways availablesynced only
DomainAlways availabledomains/create, domains/update, domains/destroy
Shopify Domain delete and update webhooks are missing key data, so Gadget does an inline sync of the Domain REST API resource when these webhooks arrive to properly discover updates and deletes.
Bulk OperationAlways availablebulk_operations/finish

Charging for your application

Shopify's app platform supports charging Shopify merchants for using your application. Applications built using Gadget 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 a variety of different billing schemes, all of which are supported by Gadget. You must 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 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 Install action of the Shopify Shop model Success Effects EffectShopify docs
One time feeCreate AppPurchaseOneTime in the Install action of the Shopify Shop model Success Effects EffectShopify 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

Charge flow

Charges created via API calls to Shopify must be accepted by merchants before they become active and money starts moving. Freshly created charge objects return a confirmationUrl URL that you must send merchants to where they can accept the charge. Before a charge object has been accepted, they exist in a pending state, and a merchant won't always accept them. You should only consider payment confirmed after the merchant has accepted the charge. Shopify will send merchants back to the returnUrl property sent in during charge creation.

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 action effects 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 new Run Code effect to the Success Effects of the Install action on the Shop model. This effect 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 /finished-payment HTTP route, where we can set up their access to the application.

models/shopifyShop/installed/createAppCharge.js
JavaScript
1module.exports = 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://my-gadget-slug.gadget.app/finished-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 // we save the `result.confirmationUrl` to the Shopify Shop record so the caller of this Gadget action can get it
26 await api.internal.shopifyShop.update(record.id, {
27 shopifyShop: { confirmationUrl: result.confirmationUrl },
28 });
29
30 logger.info(
31 { appSubscriptionId: result.appSubscription.id },
32 "created subscription"
33 );
34};

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

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

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

Free apps

Apps which 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 begin to 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 fee every month 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 different plans merchants can select or has a free option and a paid option, you need to implement functionality for merchants to select plans, and 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 String 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 routes/GET-finalize-payment.js HTTP route to use as the returnUrl when creating charges for confirming charge acceptance

First, let's make a Subscribe action on the Shopify Shop model. We can add an effect which creates the charge with Shopify for presenting to the merchant:

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

With this effect in place, we need to implement the returnUrl Shopify will send merchants who have accepted the charge to. This URL is where we should actually 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 routes/GET-finished-payment.js file to match the /finished-payment portion of the returnUrl specified when we created the charge:

routes/GET-finished-payment.js
JavaScript
1module.exports = async (request, reply) => {
2 // get an instance of the shopify-api-node API client for this shop
3 const shopify = connections.shopify.forShopId(request.params.shop_id);
4
5 // make an API call to Shopify to validate that the charge object for this shop is active
6 const result = await shopify.graphql(`
7 query {
8 node(id: "gid://shopify/AppSubscription/${request.query.charge_id}") {
9 id
10 ... on AppSubscription {
11 status
12 name
13 }
14 }
15 }
16 `);
17
18 if (result.node.status != "ACTIVE") {
19 // the merchant has not accepted the charge, so we can show them a message
20 await reply.code(400).send("Invalid charge ID specified");
21 return;
22 }
23 // the merchant has accepted the charge, so we can grant them access to our application
24 // mark the shop as paid by setting the `plan` attribute to the charged plan namemodel
25 await api.internal.shopifyShop.update(request.params.shop_id, {
26 shopifyShop: { plan: resutl.node.name },
27 });
28
29 // send the user back to the embedded app, this URL may be different depending on where your frontend is hosted
30 await reply.redirect("/");
31};

With the Subscribe action and confirmation pieces in place, we now need to trigger the new Subscribe action from our merchant-facing frontend somewhere. 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
const shop = await api.shopifyShop.subscribe(theShopId, { plan: "basic" });
window.location.href = shop.confirmationUrl;

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

JavaScript
1export const PlanSelectorButton = (props) => {
2 const [{ fetching, error, data }, createSubscription] = useAction(
3 api.shopifyShop.subscribe
4 );
5
6 const subscribe = useCallback(async (plan) => {
7 // create the resource in the backend
8 const shop = await createSubscription(theShopId, { plan });
9 // redirect the merchant to accept the charge within Shopify's interface
10 window.location.href = shop.confirmationUrl;
11 });
12
13 return (
14 <button
15 onClick={() => {
16 subscribe("basic");
17 }}
18 disabled={fetching}
19 >
20 Basic
21 </button>
22 );
23};

Upgrades and downgrades

Merchants may at some point 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, and the merchant will only be charged for the new amount on the new AppSubscription object.

For example, say a merchant is upgrading from a Basic plan costing $5 a month to a Pro plan costing $10 a month. When the merchant first installed your app, your app will have 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 have not selected a plan, or who may have enjoyed their free trial but not yet selected a plan. This means you should disable access to the key parts of your application until a merchant has selected a plan, and encourage them to do so.

Access to reading records from your application disabled using Model Filters, and app behaviour can be disabled using Run Code effects in your actions.

For example, we may choose to store a merchant's payment state 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 app can update plan field to hold whichever plan they selected. With this in place, you can begin to conditionally perform your application's duties for paying customers. For example, for an application which analyzes order fraud, we can only do the fraud analysis if the merchant has selected a current plan:

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

If need be, you can also extend your model read permissions to prevent access to records unless the merchant is on a paid plan. You can update the Gelly model filter snippet in the Roles & Permissions section of your application's settings to only return records for paid merchants. For example, if you have a Fraud Result model which BelongsTo the Shopify Shop model, you can only return Fraud Result records for merchants who are on a paid plan:

gelly
fragment Filter($user: User, $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. Using React, this can be done using a wrapper component around your app which checks plan status for every page the merchant tries to access:

components/SubscriptionWrapper.jsx
JavaScript
1import { Layout, Page, Spinner, Banner } from "@shopify/polaris";
2import { PlanSelector } from "./PlanSelector"; // up to you to implement
3
4export const SubscriptionWrapper = (props) => {
5 const [{ fetching, data: currentShop }] = useFindOne(
6 api.shopifyShop,
7 props.shopId
8 );
9 // if we're loading the current shop data, show a spinner
10 if (fetching) {
11 return (
12 <Page>
13 <Spinner />
14 </Page>
15 );
16 }
17
18 // if the shop has selected a plan, render the app and don't bug the merchant about plans
19 if (currentShop.plan) {
20 return props.children;
21 } else {
22 // the merchant has not paid for the application and should be denied access, show them the plan selection interface instead of the app
23 return (
24 <Page>
25 <Layout>
26 <Banner status="warning">
27 You must select a plan to continue using this application
28 </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 add effects to the Shopify model actions or the Shopify Sync model actions to prevent processing.

One-time charges

Shopify allows applications 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-you-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 usage-based 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 then must track how often it performs the processing, and calculate how much usage is remaining.

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

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

  • add a String field to the Shopify Shop object to store the confirmation URL to pass to the merchant
  • and add a Success Effect on the Install action within the Shopify Shop model to create the charge:
models/shopifyShop/installed/createAppCharge.js
JavaScript
1module.exports = async ({ api, record, connections }) => {
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://my-gadget-slug.gadget.app"
12 ) {
13 userErrors {
14 field
15 message
16 }
17 confirmationUrl
18 appPurchaseOneTime {
19 id
20 }
21 }
22 }
23 `);
24
25 // store the `result.confirmationUrl` that the merchant needs to visit
26 await api.internal.shopifyShop.update(record.id, {
27 shopifyShop: { confirmationUrl: result.confirmationUrl },
28 });
29
30 logger.info(
31 { appPurchaseOneTimeId: result.appSubscription.id },
32 "created one time app purchase"
33 );
34};

Then 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
const shop = await api.shopifyShop.findOne(someShopId);
window.location.href = shop.confirmationUrl;

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

JavaScript
1export const RedirectToConfirmationURL = (props) => {
2 const [{ fetching, error, data }] = useFindOne(api.shopifyShop, theShopId);
3
4 if (data.confirmationUrl) {
5 window.location.href = data.confirmationUrl;
6 }
7};

Implementing a free trial

Free trials are a great growth tool for Shopify applications so merchants can see the value an application adds before having to make the decision to pay for it. Shopify has limited native support for free trials, that allow you to start a merchant on a particular plan where the merchant will only begin being charged after some days have passed.

Free trials using Shopify's native support are registered using the same API calls as a normal recurring monthly charge. An easy way to set this up in Gadget would be to add a Success Effect to the Install action on the Shopify Shop model that creates a recurring monthly charge with the trialDays property set:

models/shopifyShop/install/startTrial.js
JavaScript
1module.exports = async ({ api, record, connections, logger }) => {
2 const result = await connections.shopify.current.graphql(`
3 mutation {
4 appSubscriptionCreate(
5 name: "Recurring Plan with 7 day trial",
6 trialDays: 7,
7 returnUrl: "http://my-gadget-slug.gadget.app",
8 lineItems: [{
9 plan: {
10 appRecurringPricingDetails: {
11 price: { amount: 10.00, currencyCode: USD }
12 }
13 }
14 }]
15 ) {
16 userErrors {
17 field
18 message
19 }
20 confirmationUrl
21 appSubscription {
22 id
23 }
24 }
25 };
26 `);
27
28 logger.info(
29 { appSubscriptionId: result.appSubscription.id },
30 "created app subscription"
31 );
32};

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 to 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 DateTime 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:

models/shopifyShop/install/startTrial.js
JavaScript
1module.exports = 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 start paying at the end of the free trial. This is most often done within the merchant-facing frontend of your application with a banner or similar which gives the merchant more information or guides them into a plan selection interface. Once the merchant has selected a plan, the banner should be hidden. This can be done with a React component which always fetches the current shop and inspects the plan state:

JavaScript
1import { Banner } from "@shopify/polaris";
2
3const trialLength = 7; // days
4
5export const PlanSelectionBanner = (props) => {
6 const [{ fetching, data: currentShop }] = useFindOne(
7 api.shopifyShop,
8 props.shopId
9 );
10 if (fetching) return null;
11 if (!currentShop.plan) {
12 const daysUntilTrialOver = Math.floor(
13 (new Date().getTime() - shop.trialStartedAt.getTime()) / (1000 * 3600 * 24)
14 );
15
16 return (
17 <Banner>
18 <p>
19 You have {daysUntilTrialOver} many day(s) left on your free trial. Please{" "}
20 <a href="/select-a-plan">select a plan</a> to keep using this great app!
21 </p>
22 </Banner>
23 );
24 }
25 return null;
26};

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.

And finally, with a trial duration tracking 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:

components/SubscriptionWrapper.jsx
JavaScript
1import { CalloutCard, Layout, Page, Spinner } from "@shopify/polaris";
2import { PlanSelector } from "./PlanSelector"; // up to you to implement
3
4// this would replace the PlanSelectionBanner component above
5export const SubscriptionWrapper = (props) => {
6 const [{ fetching, data: currentShop }] = useFindOne(
7 api.shopifyShop,
8 props.shopId
9 );
10 // if we're loading the current shop data, show a spinner
11 if (fetching) {
12 return (
13 <Page>
14 <Spinner />
15 </Page>
16 );
17 }
18
19 // if the shop has selected a plan, render the app and don't bug the merchant about plans
20 if (currentShop.plan) return props.children;
21
22 const daysUntilTrialOver = Math.floor(
23 (new Date().getTime() - shop.trialStartedAt.getTime()) / (1000 * 3600 * 24)
24 );
25 if (daysUntilTrialOver > 0) {
26 // the merchant is on a free trial, show the app and a banner encouraging them to select a plan
27 return (
28 <>
29 {props.children}
30 <Banner>
31 You have {daysUntilTrialOver} many day(s) left on your free trial. Please{" "}
32 <a href="#">select a plan</a> to keep using this great app!
33 </Banner>
34 </>
35 );
36 } else {
37 // the merchant's trial has expired, show them the plan selection interface, don't show them the app
38 return (
39 <Page>
40 <Notification>
41 Your trial has expired, please select a plan to continue using the
42 application
43 </Notification>
44 <PlanSelector />
45 </Page>
46 );
47 }
48};

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 a code effect to create an AppCredit object for some amount.

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

models/shopifyShop/credit/createAppCredit.js
JavaScript
1module.exports = async ({ api, record, params, 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 `
8 mutation CreateCredit($amount: Float!) {
9 appCreditCreate(
10 description: "application credit"
11 amount: {
12 amount: $amount,
13 currencyCode: USD
14 }
15 test: true
16 ) {
17 userErrors {
18 field
19 message
20 }
21 appCredit {
22 id
23 }
24 }
25 }
26 `,
27 { amount: params.amount }
28 );
29
30 logger.info(
31 { amount: params.amount, creditID: result.appCredit.id },
32 "credited shop"
33 );
34};
35
36// make this effect take an amount parameter for the amount to credit
37module.export.params = {
38 amount: { type: "number" },
39};

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 });

Read more about creating credits in Shopify's docs.

Troubleshooting

The sync between Shopify and my Gadget app failed.

Do you have any custom code effects running on the create action on the Shopify Sync model? A custom code effect with errors will prevent the sync from completing successfully. Try removing or editing the code effect(s) and running a manual sync.

Code effects aren't updating my records in Shopify.

You need to explicitly call out to the Shopify client in your code effect to manipulate data in Shopify. See Accessing the Shopify API.

Frequently Asked Questions

How do I make calls back to Gadget from a Shopify storefront?

You can call your Gadget app's API client in the storefront's Shopify theme. To do so, you can use the Direct Script Tag option in the Installation section of your app's API Reference.

How do I make an embedded Shopify app using Gadget?

Gadget supports being used as a backend for an app embedded in the Shopify Admin. You can read the docs for setting up the Gadget app client and Provider and go through the tutorial that walks you through setting up an embedded app with the Shopify CLI and Gadget.

When does Gadget automatically sync my Shopify connection?

Gadget will fully sync your Shopify connection on demand when you request a sync from the Connections view. Gadget will also automatically fetch all recent data from Shopify in the background in a scheduled daily sync. To see when your app was last synced with Shopify, you can visit the Connections view or view the Shopify Sync dataviewer.