Building Shopify apps
Where and how Shopify data is saved
All of the Shopify fields are mapped to a corresponding field in your Gadget model, and then these Gadget model records are updated when Shopify sends webhooks, or you sync data. 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.
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.
Gadget does not automatically sync data changed within Gadget back to Shopify. If you want to make changes to Shopify, you should make API calls to Shopify from within your Gadget Actions. See Accessing the Shopify API for more details.
Shopify models
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 want to store extra data about each record from Shopify, Gadget will happily support adding a new field to the Shopify model with any of Gadget's supported field types, including relationships, and file fields.
Fields added to Shopify models can also be marked as metafields for syncing from Shopify.
Shopify model actions
You can change the change
, add
, and remove
actions on Shopify-connected models. Extending the existing Actions allows you to react to changes within Shopify, and adding your own actions allows you to define your own business logic, which can manipulate your own data or Shopify's in the context of one Shopify record.
For example, you can react to a Shopify product being created by adding a code snippet to the create
action on the shopifyProduct
model:
api/models/shopifyProduct/create.jsJavaScriptexport async function onSuccess({ record, logger }) {logger.info({ record }, "shopify product was just created");}
Gadget will run the create
action for this model any time a product/create
webhook arrives from Shopify or if a new Product record is discovered during a sync.
You can also add an action to the shopifyProduct
model that runs your own business logic, triggerable by you instead of a webhook. For example, you can add an action to update a product's title to be all uppercase. To do this, you would add a new file in the actions
folder of the model, and implement the run
function:
api/models/shopifyProduct/upcase.jsJavaScript1export async function run({ record, connections }) {2 if (record.changed("title")) {3 const shopify = connections.shopify.current;4 await shopify.graphql(5 `mutation ($input: ProductInput!) {6 productUpdate(input: $input) {7 product {8 title9 }10 userErrors {11 message12 }13 }14 }`,15 {16 input: {17 id: `gid://shopify/Product/${record.id}`,18 title: record.title.toUpperCase(),19 },20 }21 );22 }23}
This action would be available to run on any shopifyProduct
record in your app.
Removing default model actions
You can also remove the existing create
, update
, or delete
actions from your models, but this will prevent the connection from being able to perform those actions in response to webhooks/syncs. For example, if you remove the delete
action from the shopifyProduct
model, your Gadget database will no longer remove products when Shopify sends webhooks, preserving a long-term record of all products ever seen.
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.
Key Shopify models
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.
shopifyShop
The shopifyShop
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 an action on the creation of a shopifyShop
record that will then execute your logic on app installation.
shopifySync
The shopifySync
model represents the data sync between Shopify and Gadget for any given connected Shopify shop. Like the shopifyShop
model, you can create an action on the creation of a sync record, which will execute whenever a sync is initiated between Shopify and Gadget.
shopifyGdprRequest
The shopifyGdprRequest
model represents requests from Shopify's mandatory webhooks in compliance with General Data Protection Regulation (GDPR). You can add custom code to the shopifyGdprRequest
model's create
Action to handle the request as needed, ensuring your app is GDPR compliant.
To view your GDPR webhook URLs, or read more about the Shopify plugin's GDPR functionality, see below.
Protected Customer Data Access
You will need to ensure that you request access to Protected Customer Data if you are using one of the following models:
- Shopify Balance Transaction
- Shopify Cart
- Shopify Checkout
- Shopify Comment
- Shopify Customer
- Shopify Draft Order
- Shopify Event
- Shopify Fulfillment Order
- Shopify Order
- Shopify Company
To request access, go to your Partner Dashboard App Setup, then click on API access section in the left nav and find the section labeled Protected customer data access. See Shopify's documentation for more details.
If your app needs access to fields such as customer name, email, phone number, or address, you need to make sure you request individual field access in the optional Protected customer fields section of the customer data access page. If you do not select the individual fields you need access to, Shopify will not send this data via sync or webhook, and the records in your Gadget app database will have null or empty values instead.
Adding code to actions
Shopify models can be given business logic just like any other model in Gadget using model actions. Most often, developers add code to the onSuccess
function of list for the default model actions (create
, update
, or delete
) on a Shopify model. This function can make calls to other APIs, create or change data in the Gadget app, or make calls back to Shopify. Within the action functions, 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.
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 code to the shopifyProduct
model's update
Action that does this in code:
api/models/shopifyProduct/actions/update.jsJavaScript1export async function onSuccess({ api, record, params, logger, connections }) {2 if (record.changed("body")) {3 const newTags = (await extractKeywords(record.body)).slice(20);4 const allowedTags = (await api.allowedTag.findMany()).map(5 (record) => record.tag6 );7 logger.info({ newTags }, "saving new tags");89 await shopify.graphql(10 `mutation ($input: ProductInput!) {11 productUpdate(input: $input) {12 product {13 title14 }15 userErrors {16 message17 }18 }19 }`,20 {21 input: {22 id: `gid://shopify/Product/${record.id}`,23 tags: newTags,24 },25 }26 );27 }28}
The above code extracts tags from the product's body
field using a module from npm, 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
Follow our tutorials and learn how to connect Gadget and Shopify in just a few minutes!
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 action 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 aShopify
client instance for a specific shop IDconnections.shopify.forShopDomain
allows creating aShopify
client instance for a specific myshopify domain.
For example, we can use connections.shopify
to access a Shopify client for a given store and then make a call to the Products GraphQL API in Shopify to create a new product record:
JavaScript1export async function onSuccess({ connections }) {2 const shopifyClient = await connections.shopify.forShopDomain(3 "the-store.myshopify.com"4 );56 await shopifyClient.graphql(7 `mutation ($input: ProductInput!) {8 productCreate(input: $input) {9 product {10 title11 descriptionHtml12 tags13 }14 }15 userErrors {16 message17 }18 }`,19 {20 input: {21 id: `gid://shopify/Product/1234567890`,22 title: "New Product",23 descriptionHtml: "This is the latest product on The Store",24 tags: ["product", "new"],25 },26 }27 );28}
If you prefer to use another client, the options
attribute of the provided client has all the necessary details to construct one:
1import MyShopifyClient from "my-shopify-client";23export async function onSuccess({ connections }) {4 const shopifyClient = await connections.shopify.forShopDomain(5 "the-store.myshopify.com"6 );7 const myClient = new MyShopifyClient({8 myshopifyDomain: shopifyClient.options.shopName,9 accessToken: shopifyClient.options.accessToken,10 });11 // ...12}
Querying Shopify's GraphQL API
The shopify-api-node
library also has a graphql
method that allows you to make GraphQL queries to Shopify.
Here's a simple example that queries for the current shop's name:
Example: fetch the store name using a GraphQL queryJavaScript1export async function onSuccess({ connections }) {2 // https://shopify.dev/docs/api/admin-graphql/2024-04/queries/shop3 // get the shopify-api-node client for the current shop4 const shopify = connections.shopify.current;5 if (shopify) {6 // use the client to make the GraphQL call7 await shopify.graphql(`8 query {9 shop {10 name11 }12 }13 `);14 }15}
This is a more complex example that creates a new discount for the current shop:
Example: create a new discount using a GraphQL mutationJavaScript1export async function onSuccess({ connections }) {2 // https://shopify.dev/docs/api/admin-graphql/2024-04/mutations/discountAutomaticAppCreate3 // get the shopify-api-node client for the current shop4 const shopify = connections.shopify.current;56 if (shopify) {7 // use the client to make the GraphQL call8 await shopify.graphql(9 `mutation ($automaticAppDiscount: DiscountAutomaticAppInput!) {10 discountAutomaticAppCreate(automaticAppDiscount: $automaticAppDiscount) {11 automaticAppDiscount {12 discountId13 title14 }15 userErrors {16 message17 }18 }19 }`,20 {21 automaticAppDiscount: {22 functionId: process.env["FUNCTION_ID"],23 title: "my new discount",24 startsAt: new Date(),25 },26 }27 );28 }29}
When to make API calls to Shopify in actions
For reading data within your actions, 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. It 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 actions, API calls should generally be placed within onSuccess
function. onSuccess
runs after the run
function has been completed without errors, which gives more confidence that your business logic has worked and saved changes to the database. This makes it less likely that your system and the external system will get out of sync. Gadget, by default, will execute the run
function of your app's Actions inside a database transaction. When the run
function fails 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
function, 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 4 requests per second to any given shop. To avoid errors from hitting this rate limit, Gadget recommends two strategies:
- 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.
- Use the
shopify-api-node
object returned by Gadget'sconnections.shopify
helper, which is pre-configured to retry when encountering rate limit errors.shopify-api-node
will retry requests, respecting Shopify'sRetry-After
header. If you need to make a write request to Shopify, or a read of up-to-date data, this is the best option.
The number of times connections.shopify
will retry a request is configurable. By default it will retry 2 times (or 6 times if your app is on a Gadget version prior to v1.1
). If you would like to configure how many times it will retry you can do so on an action-by-action basis.
api/models/shopifyProduct/actions/update.jsJavaScript1export async function run({ api, record, params, logger, connections }) {2 // this will retry the request 10 times before failing3 connections.shopify.maxRetries = 10;45 await connections.shopify.current.graphql(6 `mutation ($input: ProductInput!) {7 productUpdate(input: $input) {8 product {9 id10 tags11 }12 userErrors {13 message14 }15 }16 }`,17 {18 input: {19 id: `gid://shopify/Product/${record.id}`,20 tags: ["foo", "bar", "baz"],21 },22 }23 );24}
When connections.shopify
retries a request to Shopify, it will wait for the Retry-After
header to determine how long to wait before
retrying. This can lead to a request taking longer than expected to complete and increase the amount of request time your app uses. If you
are repeatedly hitting Shopify's rate limit you may want to consider running your action as a background
action and use concurrency controls to make sure that you are only running actions when it likely that they
will succeed.
Read more about Shopify's rate limits here: https://shopify.dev/api/usage/rate-limits
Current shop tenancy in actions
As the frontend 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 action is triggered by a webhook or sync, the current record that is being processed.
The following information about the current shop is accessible from the connections.shopify
object supplied in the action context:
| The shop id of the current shop |
| The myshopify domain of the the current shop |
| The client ID of the Shopify app that the current shop installed |
| The client secret of the Shopify app that the current shop installed |
| The Shopify session token that was used to authenticate the current shop for the current request |
| The Shopify user id attached to the Shopify session token that was used to authenticate the current shop for the current request |
It is preferred to use connections.shopify.current
in actions 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.
api/models/shopifyProduct/actions/update-product.jsJavaScript1export async function onSuccess({ api, record, params, logger, connections }) {2 if (connections.shopify.current) {3 await connections.shopify.current.graphql(4 `mutation ($input: ProductInput!) {5 productUpdate(input: $input) {6 product {7 id8 tags9 }10 userErrors {11 message12 }13 }14 }`,15 {16 input: {17 id: `gid://shopify/Product/${record.id}`,18 tags: ["foo", "bar", "baz"],19 },20 }21 );22 }23}
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.
Current shop tenancy in HTTP routes
If you are looking to get the current shop's context in a custom HTTP route being called from an app embedded in the Shopify admin, you can use the useFetch
hook from @gadgetinc/react
or the Gadget client's api.fetch
method. You can then access the current shop id in your Gadget route.
You may be able to use a Global Action instead of an HTTP route. One advantage to Global Actions is that they are included in a Gadget project's generated GraphQL API, so shop tenancy for an embedded app is handled automatically. For more differences between global actions and routes, see global actions vs HTTP routes.
Here's an example of how we can do this in a React component from an embedded Shopify app:
MyReactComponent.jsxReact1// this is your Gadget project's API client2import { api } from "../api";3import { useFetch } from "@gadgetinc/react";45export default function MyReactComponent() {6 const [data, setData] = useState(null);7 const [{ data, fetching, error }, send] = useFetch("/custom", { json: true });89 return (10 <div>11 <button onClick={() => send()}>Fetch data</button>12 {fetching && <div>Loading...</div>}13 {error && <div>Error: {error}</div>}14 <code>15 <pre>{JSON.stringify(data, null, 2)}</pre>16 </code>17 </div>18 );19}
The current Shopify store id can be accessed in a Gadget route using request.connections.shopify.currentShopId
. The current session is available in a route file with request.applicationSession
.
api/routes/GET-custom.jsJavaScript1export default async function route({ request, reply }) {2 const currentSession = request.applicationSession;3 const currentShopConnection = request.connections.shopify.current;4 const currentShopId = request.connections.shopify.currentShopId;56 await reply7 .code(200)8 .send({ currentSession, currentShopId, currentShopConnection });9}
The current shop connection can be used to make API requests against the Shopify store that sent the request to the route.
By default, the Gadget API request.api
object will be able to read model records across shops. You can use the current shop id as a filter if you only need a single shop's data in your route code.
api/routes/GET-custom.jsJavaScript1export default async function route({ request, reply }) {2 const currentShopId = request.connections.shopify.currentShopId;34 const products = await request.api.shopifyProduct.findMany({5 filter: {6 shopId: {7 equals: currentShopId,8 },9 },10 });1112 await reply.code(200).send({ products });13}
Accessing the current Shopify user
To customize your embedded app experience based on which user is accessing your app through the Shopify admin, create an action to exchange the current Shopify session token for information about the current user.
api/actions/fetchShopifyUser.jsJavaScript1export async function run({ params, logger, api, connections }) {2 const currentSession = connections.shopify.currentSession;34 let shopifyUser;56 if (currentSession) {7 const response = await fetch(8 `https://${connections.shopify.currentShopDomain}/admin/oauth/access_token`,9 {10 method: "POST",11 body: JSON.stringify({12 client_id: connections.shopify.currentClientId,13 client_secret: connections.shopify.currentClientSecret,14 subject_token: currentSession.token,15 grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",16 subject_token_type: "urn:ietf:params:oauth:token-type:id_token",17 requested_token_type:18 "urn:shopify:params:oauth:token-type:online-access-token",19 }),20 headers: {21 "Content-Type": "application/json",22 Accept: "application/json",23 },24 }25 );2627 const responseJson = await response.json();2829 shopifyUser = responseJson.associated_user;30 }3132 return { shopifyUser };33}
This action can then be called from your embedded app frontend using useGlobalAction
.
Webhook infinite loop handling
It is common for Gadget actions 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. Without care, this can cause infinite webhook loops, which chew 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 function 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.
Shop installs
You can click Shop 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.
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.
You can also perform operations on multiple stores at once by selecting multiple stores and then clicking Sync or Register Webhooks buttons.
The 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.
In most cases, selecting Register Webhooks from the store options button or clicking Register Webhooks with multiple stores selected should fix missing webhook topics. 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.
There are two ways to associate a Shopify store with a custom app:
- Through a merchant install link
- 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.
Editing Shopify 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.
Adding new scopes
If you have a custom application built through the Partners dashboard or Shopify CLI and edit your connection to require additional scopes, you will need to re-authenticate your app for all stores on which it is installed. To do this, you can go through the app installation process again. This involves re-installing your app on development stores in the Partners Dashboard.
If you are building an embedded application using React, and use the Gadget Provider from our @gadgetinc/react-shopify-app-bridge
package, re-authentication will be handled for you.
If you have an application built through a store admin, you must ensure that the scopes selected in the admin match the scopes required by your Gadget connection. If they are out of sync, navigate to the Shop Installs page. From there, you can click the Sync button to the right of the store you are trying to sync. You may also need to reregister your webhooks.
Adding new models without new scopes
You might edit your connection to only add new models, not new scopes. In this case, you do not need to reauthenticate or reinstall, but you may need to register new webhooks for your newly selected models. You can check to see if you need to register webhooks on the Installs page for your connection.
For example, you might have a connection set up that has the read_products
scope selected along with the Product model. If you edit your connection to include the Collection model, you do not need new scopes, but you need to register the collections/create
, collections/update
, and collections/delete
webhooks manually.
To register webhooks, you need to go to the Installs page for your Connection in Gadget, click the More button (…) for any stores you have re-authenticated on and select the Register Webhooks option.
Once the webhooks are registered, you are ready to continue building your app using the newly selected scopes and models.
Upgrading the Shopify API version
Shopify releases a new API version every 3 months at the beginning of the quarter, and each stable version is supported for a minimum of 12 months. This generally means that a given version will become unsupported 12 months after its release. Shopify recommends upgrading your apps to make requests to the latest stable API version every quarter. Gadget makes this process simple by allowing you to edit your Shopify Connection's API version.
To upgrade the API, go to the Connections page and select your Shopify Connection. There you will see the current API version and a link to upgrade.
Click on Upgrade and select the API version that you would like to upgrade to. Note that downgrading API versions is not currently supported.
Once an API version has been selected, the page will display:
- Added - fields that have been added to models.
- Deprecated - fields that Shopify has marked as deprecated in the API version.
- Modified - an enum value has been added to a field or field type has changed.
- Disconnected - fields that have been removed by Shopify which Gadget will now allow you to manage yourself. Gadget will never delete fields that Shopify has disconnected! However, these fields will no longer be updated via Shopify webhooks or the nightly sync.
Click on Upgrade and then Confirm to complete the API version upgrade.
You should ensure that the Event Version specified in your Partner Dashboard App Setup or Admin App Configuration page is set to the same version.
After upgrading your API version, you may wish to backfill existing records with new values by running a forced sync.
Lastly, if you are upgrading a Shopify API version that is 2022-07
or older to a version that is 2022-10
or newer, you will need to ensure that you request access to Protected Customer Data. See here for more details on how to request access.
import from "web/src/components/markdown/Typography"
Shopify GDPR and Privacy Compliance
For apps listed in Shopify's public app store, Shopify requires apps to meet mandatory minimum privacy rules, which includes implementing GDPR webhooks Shopify will send your app.
Read more about Shopify's privacy compliance rules in Shopify's docs.
Gadget's infrastructure meets Shopify's requirements for secure processing of data, but you must take two additional steps to be compliant with Shopify:
- input your GDPR webhook URLs into the Shopify Partners dashboard
- implement the functions in the
shopifyGdprRequest
model to purge data upon request from a merchant or customer
GDPR requests
Gadget supports processing GDPR request webhooks from Shopify using the shopifyGDPRRequest
model. The Shopify plugin includes webhook URLs that you can paste into the Shopify Partners dashboard which will trigger the right action on this model. It's up to you to correctly delete data within your application to honor Shopify's privacy rules.
To enable GDPR webhook requests from Shopify, go to the Apps page on the Shopify Partner Dashboard, select your application, click Configuration, and paste https://<your-environment-domain>/api/webhooks/shopify
in the GDPR mandatory webhooks section.
See the example code within the actions on your shopifyGdprRequest
model for guidance on how to process these incoming webhooks.