Webhookobject
Contains functions for verifying Shopify webhooks.
The format of the admin
object returned by this function changes with the future flag. Learn more about gradual feature adoption.
Verifies requests coming from Shopify webhooks.
- Anchor to requestrequestRequestrequired
AuthenticateWebhook
- request
Request
Promise<WebhookContext<Future, Resources, Topics>>
export type AuthenticateWebhook<
Future extends FutureFlagOptions,
Resources extends ShopifyRestResources,
Topics = string | number | symbol,
> = (request: Request) => Promise<WebhookContext<Future, Resources, Topics>>;
WebhookContext
WebhookContextWithoutSession<Topics> | WebhookContextWithSession<Future, Resources, Topics>
WebhookContextWithoutSession
- session
undefined
- admin
undefined
- apiVersion
The API version used for the webhook.
string
- shop
The shop where the webhook was triggered.
string
- topic
The topic of the webhook.
Topics
- webhookId
A unique ID for the webhook. Useful to keep track of which events your app has already processed.
string
- payload
The payload from the webhook request.
Record<string, any>
- subTopic
The sub-topic of the webhook. This is only available for certain webhooks.
string
export interface WebhookContextWithoutSession<Topics = string | number | symbol>
extends Context<Topics> {
session: undefined;
admin: undefined;
}
WebhookContextWithSession
- session
A session with an offline token for the shop. Returned only if there is a session for the shop.
Session
- admin
An admin context for the webhook. Returned only if there is a session for the shop.
WebhookAdminContext<Future, Resources>
- apiVersion
The API version used for the webhook.
string
- shop
The shop where the webhook was triggered.
string
- topic
The topic of the webhook.
Topics
- webhookId
A unique ID for the webhook. Useful to keep track of which events your app has already processed.
string
- payload
The payload from the webhook request.
Record<string, any>
- subTopic
The sub-topic of the webhook. This is only available for certain webhooks.
string
export interface WebhookContextWithSession<
Future extends FutureFlagOptions,
Resources extends ShopifyRestResources,
Topics = string | number | symbol,
> extends Context<Topics> {
/**
* A session with an offline token for the shop.
*
* Returned only if there is a session for the shop.
*/
session: Session;
/**
* An admin context for the webhook.
*
* Returned only if there is a session for the shop.
*
* @example
* <caption>[V3] Webhook admin context.</caption>
* <description>With the `v3_webhookAdminContext` future flag enabled, use the `admin` object in the context to interact with the Admin API.</description>
* ```ts
* // /app/routes/webhooks.tsx
* import { ActionFunctionArgs } from "@remix-run/node";
* import { authenticate } from "../shopify.server";
*
* export async function action({ request }: ActionFunctionArgs) {
* const { admin } = await authenticate.webhook(request);
*
* const response = await admin?.graphql(
* `#graphql
* mutation populateProduct($input: ProductInput!) {
* productCreate(input: $input) {
* product {
* id
* }
* }
* }`,
* { variables: { input: { title: "Product Name" } } }
* );
*
* const productData = await response.json();
* return json({ data: productData.data });
* }
* ```
*
* @example
* <caption>Webhook admin context.</caption>
* <description>Use the `admin` object in the context to interact with the Admin API. This format will be removed in V3 of the package.</description>
* ```ts
* // /app/routes/webhooks.tsx
* import { json, ActionFunctionArgs } from "@remix-run/node";
* import { authenticate } from "../shopify.server";
*
* export async function action({ request }: ActionFunctionArgs) {
* const { admin } = await authenticate.webhook(request);
*
* const response = await admin?.graphql.query<any>({
* data: {
* query: `#graphql
* mutation populateProduct($input: ProductInput!) {
* productCreate(input: $input) {
* product {
* id
* }
* }
* }`,
* variables: { input: { title: "Product Name" } },
* },
* });
*
* const productData = response?.body.data;
* return json({ data: productData.data });
* }
* ```
*/
admin: WebhookAdminContext<Future, Resources>;
}
Session
Stores App information from logged in merchants so they can make authenticated requests to the Admin API.
- id
The unique identifier for the session.
string
- shop
The Shopify shop domain, such as `example.myshopify.com`.
string
- state
The state of the session. Used for the OAuth authentication code flow.
string
- isOnline
Whether the access token in the session is online or offline.
boolean
- scope
The desired scopes for the access token, at the time the session was created.
string
- expires
The date the access token expires.
Date
- accessToken
The access token for the session.
string
- onlineAccessInfo
Information on the user for the session. Only present for online sessions.
OnlineAccessInfo
- isActive
Whether the session is active. Active sessions have an access token that is not expired, and has the given scopes.
(scopes: string | string[] | AuthScopes) => boolean
- isScopeChanged
Whether the access token has the given scopes.
(scopes: string | string[] | AuthScopes) => boolean
- isExpired
Whether the access token is expired.
(withinMillisecondsOfExpiry?: number) => boolean
- toObject
Converts an object with data into a Session.
() => SessionParams
- equals
Checks whether the given session is equal to this session.
(other: Session) => boolean
- toPropertyArray
Converts the session into an array of key-value pairs.
(returnUserData?: boolean) => [string, string | number | boolean][]
export class Session {
public static fromPropertyArray(
entries: [string, string | number | boolean][],
returnUserData = false,
): Session {
if (!Array.isArray(entries)) {
throw new InvalidSession(
'The parameter is not an array: a Session cannot be created from this object.',
);
}
const obj = Object.fromEntries(
entries
.filter(([_key, value]) => value !== null && value !== undefined)
// Sanitize keys
.map(([key, value]) => {
switch (key.toLowerCase()) {
case 'isonline':
return ['isOnline', value];
case 'accesstoken':
return ['accessToken', value];
case 'onlineaccessinfo':
return ['onlineAccessInfo', value];
case 'userid':
return ['userId', value];
case 'firstname':
return ['firstName', value];
case 'lastname':
return ['lastName', value];
case 'accountowner':
return ['accountOwner', value];
case 'emailverified':
return ['emailVerified', value];
default:
return [key.toLowerCase(), value];
}
}),
);
const sessionData = {} as SessionParams;
const onlineAccessInfo = {
associated_user: {},
} as OnlineAccessInfo;
Object.entries(obj).forEach(([key, value]) => {
switch (key) {
case 'isOnline':
if (typeof value === 'string') {
sessionData[key] = value.toString().toLowerCase() === 'true';
} else if (typeof value === 'number') {
sessionData[key] = Boolean(value);
} else {
sessionData[key] = value;
}
break;
case 'scope':
sessionData[key] = value.toString();
break;
case 'expires':
sessionData[key] = value ? new Date(Number(value)) : undefined;
break;
case 'onlineAccessInfo':
onlineAccessInfo.associated_user.id = Number(value);
break;
case 'userId':
if (returnUserData) {
onlineAccessInfo.associated_user.id = Number(value);
break;
}
case 'firstName':
if (returnUserData) {
onlineAccessInfo.associated_user.first_name = String(value);
break;
}
case 'lastName':
if (returnUserData) {
onlineAccessInfo.associated_user.last_name = String(value);
break;
}
case 'email':
if (returnUserData) {
onlineAccessInfo.associated_user.email = String(value);
break;
}
case 'accountOwner':
if (returnUserData) {
onlineAccessInfo.associated_user.account_owner = Boolean(value);
break;
}
case 'locale':
if (returnUserData) {
onlineAccessInfo.associated_user.locale = String(value);
break;
}
case 'collaborator':
if (returnUserData) {
onlineAccessInfo.associated_user.collaborator = Boolean(value);
break;
}
case 'emailVerified':
if (returnUserData) {
onlineAccessInfo.associated_user.email_verified = Boolean(value);
break;
}
// Return any user keys as passed in
default:
sessionData[key] = value;
}
});
if (sessionData.isOnline) {
sessionData.onlineAccessInfo = onlineAccessInfo;
}
const session = new Session(sessionData);
return session;
}
/**
* The unique identifier for the session.
*/
readonly id: string;
/**
* The Shopify shop domain, such as `example.myshopify.com`.
*/
public shop: string;
/**
* The state of the session. Used for the OAuth authentication code flow.
*/
public state: string;
/**
* Whether the access token in the session is online or offline.
*/
public isOnline: boolean;
/**
* The desired scopes for the access token, at the time the session was created.
*/
public scope?: string;
/**
* The date the access token expires.
*/
public expires?: Date;
/**
* The access token for the session.
*/
public accessToken?: string;
/**
* Information on the user for the session. Only present for online sessions.
*/
public onlineAccessInfo?: OnlineAccessInfo;
constructor(params: SessionParams) {
Object.assign(this, params);
}
/**
* Whether the session is active. Active sessions have an access token that is not expired, and has the given scopes.
*/
public isActive(scopes: AuthScopes | string | string[]): boolean {
return (
!this.isScopeChanged(scopes) &&
Boolean(this.accessToken) &&
!this.isExpired()
);
}
/**
* Whether the access token has the given scopes.
*/
public isScopeChanged(scopes: AuthScopes | string | string[]): boolean {
const scopesObject =
scopes instanceof AuthScopes ? scopes : new AuthScopes(scopes);
return !scopesObject.equals(this.scope);
}
/**
* Whether the access token is expired.
*/
public isExpired(withinMillisecondsOfExpiry = 0): boolean {
return Boolean(
this.expires &&
this.expires.getTime() - withinMillisecondsOfExpiry < Date.now(),
);
}
/**
* Converts an object with data into a Session.
*/
public toObject(): SessionParams {
const object: SessionParams = {
id: this.id,
shop: this.shop,
state: this.state,
isOnline: this.isOnline,
};
if (this.scope) {
object.scope = this.scope;
}
if (this.expires) {
object.expires = this.expires;
}
if (this.accessToken) {
object.accessToken = this.accessToken;
}
if (this.onlineAccessInfo) {
object.onlineAccessInfo = this.onlineAccessInfo;
}
return object;
}
/**
* Checks whether the given session is equal to this session.
*/
public equals(other: Session | undefined): boolean {
if (!other) return false;
const mandatoryPropsMatch =
this.id === other.id &&
this.shop === other.shop &&
this.state === other.state &&
this.isOnline === other.isOnline;
if (!mandatoryPropsMatch) return false;
const copyA = this.toPropertyArray(true);
copyA.sort(([k1], [k2]) => (k1 < k2 ? -1 : 1));
const copyB = other.toPropertyArray(true);
copyB.sort(([k1], [k2]) => (k1 < k2 ? -1 : 1));
return JSON.stringify(copyA) === JSON.stringify(copyB);
}
/**
* Converts the session into an array of key-value pairs.
*/
public toPropertyArray(
returnUserData = false,
): [string, string | number | boolean][] {
return (
Object.entries(this)
.filter(
([key, value]) =>
propertiesToSave.includes(key) &&
value !== undefined &&
value !== null,
)
// Prepare values for db storage
.flatMap(([key, value]): [string, string | number | boolean][] => {
switch (key) {
case 'expires':
return [[key, value ? value.getTime() : undefined]];
case 'onlineAccessInfo':
// eslint-disable-next-line no-negated-condition
if (!returnUserData) {
return [[key, value.associated_user.id]];
} else {
return [
['userId', value?.associated_user?.id],
['firstName', value?.associated_user?.first_name],
['lastName', value?.associated_user?.last_name],
['email', value?.associated_user?.email],
['locale', value?.associated_user?.locale],
['emailVerified', value?.associated_user?.email_verified],
['accountOwner', value?.associated_user?.account_owner],
['collaborator', value?.associated_user?.collaborator],
];
}
default:
return [[key, value]];
}
})
// Filter out tuples with undefined values
.filter(([_key, value]) => value !== undefined)
);
}
}
OnlineAccessInfo
- expires_in
How long the access token is valid for, in seconds.
number
- associated_user_scope
The effective set of scopes for the session.
string
- associated_user
The user associated with the access token.
OnlineAccessUser
export interface OnlineAccessInfo {
/**
* How long the access token is valid for, in seconds.
*/
expires_in: number;
/**
* The effective set of scopes for the session.
*/
associated_user_scope: string;
/**
* The user associated with the access token.
*/
associated_user: OnlineAccessUser;
}
OnlineAccessUser
- id
The user's ID.
number
- first_name
The user's first name.
string
- last_name
The user's last name.
string
- email
The user's email address.
string
- email_verified
Whether the user has verified their email address.
boolean
- account_owner
Whether the user is the account owner.
boolean
- locale
The user's locale.
string
- collaborator
Whether the user is a collaborator.
boolean
export interface OnlineAccessUser {
/**
* The user's ID.
*/
id: number;
/**
* The user's first name.
*/
first_name: string;
/**
* The user's last name.
*/
last_name: string;
/**
* The user's email address.
*/
email: string;
/**
* Whether the user has verified their email address.
*/
email_verified: boolean;
/**
* Whether the user is the account owner.
*/
account_owner: boolean;
/**
* The user's locale.
*/
locale: string;
/**
* Whether the user is a collaborator.
*/
collaborator: boolean;
}
AuthScopes
A class that represents a set of access token scopes.
- has
Checks whether the current set of scopes includes the given one.
(scope: string | string[] | AuthScopes) => boolean
- equals
Checks whether the current set of scopes equals the given one.
(otherScopes: string | string[] | AuthScopes) => boolean
- toString
Returns a comma-separated string with the current set of scopes.
() => string
- toArray
Returns an array with the current set of scopes.
() => any[]
class AuthScopes {
public static SCOPE_DELIMITER = ',';
private compressedScopes: Set<string>;
private expandedScopes: Set<string>;
constructor(scopes: string | string[] | AuthScopes | undefined) {
let scopesArray: string[] = [];
if (typeof scopes === 'string') {
scopesArray = scopes.split(
new RegExp(`${AuthScopes.SCOPE_DELIMITER}\\s*`),
);
} else if (Array.isArray(scopes)) {
scopesArray = scopes;
} else if (scopes) {
scopesArray = Array.from(scopes.expandedScopes);
}
scopesArray = scopesArray
.map((scope) => scope.trim())
.filter((scope) => scope.length);
const impliedScopes = this.getImpliedScopes(scopesArray);
const scopeSet = new Set(scopesArray);
const impliedSet = new Set(impliedScopes);
this.compressedScopes = new Set(
[...scopeSet].filter((x) => !impliedSet.has(x)),
);
this.expandedScopes = new Set([...scopeSet, ...impliedSet]);
}
/**
* Checks whether the current set of scopes includes the given one.
*/
public has(scope: string | string[] | AuthScopes | undefined) {
let other: AuthScopes;
if (scope instanceof AuthScopes) {
other = scope;
} else {
other = new AuthScopes(scope);
}
return (
other.toArray().filter((x) => !this.expandedScopes.has(x)).length === 0
);
}
/**
* Checks whether the current set of scopes equals the given one.
*/
public equals(otherScopes: string | string[] | AuthScopes | undefined) {
let other: AuthScopes;
if (otherScopes instanceof AuthScopes) {
other = otherScopes;
} else {
other = new AuthScopes(otherScopes);
}
return (
this.compressedScopes.size === other.compressedScopes.size &&
this.toArray().filter((x) => !other.has(x)).length === 0
);
}
/**
* Returns a comma-separated string with the current set of scopes.
*/
public toString() {
return this.toArray().join(AuthScopes.SCOPE_DELIMITER);
}
/**
* Returns an array with the current set of scopes.
*/
public toArray() {
return [...this.compressedScopes];
}
private getImpliedScopes(scopesArray: string[]): string[] {
return scopesArray.reduce((array: string[], current: string) => {
const matches = current.match(/^(unauthenticated_)?write_(.*)$/);
if (matches) {
array.push(`${matches[1] ? matches[1] : ''}read_${matches[2]}`);
}
return array;
}, []);
}
}
SessionParams
- [key: string]
any
- id
The unique identifier for the session.
string
- shop
The Shopify shop domain.
string
- state
The state of the session. Used for the OAuth authentication code flow.
string
- isOnline
Whether the access token in the session is online or offline.
boolean
- scope
The scopes for the access token.
string
- expires
The date the access token expires.
Date
- accessToken
The access token for the session.
string
- onlineAccessInfo
Information on the user for the session. Only present for online sessions.
OnlineAccessInfo | StoredOnlineAccessInfo
export interface SessionParams {
/**
* The unique identifier for the session.
*/
readonly id: string;
/**
* The Shopify shop domain.
*/
shop: string;
/**
* The state of the session. Used for the OAuth authentication code flow.
*/
state: string;
/**
* Whether the access token in the session is online or offline.
*/
isOnline: boolean;
/**
* The scopes for the access token.
*/
scope?: string;
/**
* The date the access token expires.
*/
expires?: Date;
/**
* The access token for the session.
*/
accessToken?: string;
/**
* Information on the user for the session. Only present for online sessions.
*/
onlineAccessInfo?: OnlineAccessInfo | StoredOnlineAccessInfo;
/**
* Additional properties of the session allowing for extension
*/
[key: string]: any;
}
StoredOnlineAccessInfo
Omit<OnlineAccessInfo, 'associated_user'> & {
associated_user: Partial<OnlineAccessUser>;
}
WebhookAdminContext
FeatureEnabled<Future, 'v3_webhookAdminContext'> extends true
? AdminApiContext<Resources>
: LegacyWebhookAdminApiContext<Resources>
AdminContext
Config['isEmbeddedApp'] extends false
? NonEmbeddedAdminContext<Config, Resources>
: EmbeddedAdminContext<Config, Resources>
NonEmbeddedAdminContext
- session
The session for the user who made the request. This comes from the session storage which `shopifyApp` uses to store sessions in your database of choice. Use this to get shop or user-specific data.
Session
- admin
Methods for interacting with the GraphQL / REST Admin APIs for the store that made the request.
AdminApiContext<Resources>
- billing
Billing methods for this store, based on the plans defined in the `billing` config option.
BillingContext<Config>
- cors
A function that ensures the CORS headers are set correctly for the response.
EnsureCORSFunction
export interface NonEmbeddedAdminContext<
Config extends AppConfigArg,
Resources extends ShopifyRestResources = ShopifyRestResources,
> extends AdminContextInternal<Config, Resources> {}
AdminApiContext
- rest
Methods for interacting with the Shopify Admin REST API There are methods for interacting with individual REST resources. You can also make `GET`, `POST`, `PUT` and `DELETE` requests should the REST resources not meet your needs.
RestClientWithResources<Resources>
- graphql
Methods for interacting with the Shopify Admin GraphQL API
GraphQLClient<AdminOperations>
export interface AdminApiContext<
Resources extends ShopifyRestResources = ShopifyRestResources,
> {
/**
* Methods for interacting with the Shopify Admin REST API
*
* There are methods for interacting with individual REST resources. You can also make `GET`, `POST`, `PUT` and `DELETE` requests should the REST resources not meet your needs.
*
* {@link https://shopify.dev/docs/api/admin-rest}
*
* @example
* <caption>Using REST resources.</caption>
* <description>Getting the number of orders in a store using REST resources. Visit the [Admin REST API references](/docs/api/admin-rest) for examples on using each resource. </description>
*
* ```ts
* // /app/routes/**\/*.ts
* import { LoaderFunctionArgs, json } from "@remix-run/node";
* import { authenticate } from "../shopify.server";
*
* export const loader = async ({ request }: LoaderFunctionArgs) => {
* const {
* admin,
* session,
* } = await authenticate.admin(request);
*
* return json(
* admin.rest.resources.Order.count({ session }),
* );
* };
* ```
*
* ```ts
* // /app/shopify.server.ts
* import { shopifyApp } from "@shopify/shopify-app-remix/server";
* import { restResources } from "@shopify/shopify-api/rest/admin/2023-07";
*
* const shopify = shopifyApp({
* restResources,
* // ...etc
* });
* export default shopify;
* export const authenticate = shopify.authenticate;
* ```
*
* @example
* <caption>Performing a GET request to the REST API.</caption>
* <description>Use `admin.rest.get` to make custom requests to make a request to to the `customer/count` endpoint</description>
*
* ```ts
* // /app/routes/**\/*.ts
* import { LoaderFunctionArgs, json } from "@remix-run/node";
* import { authenticate } from "../shopify.server";
*
* export const loader = async ({ request }: LoaderFunctionArgs) => {
* const {
* admin,
* session,
* } = await authenticate.admin(request);
*
* const response = await admin.rest.get({
* path: "/customers/count.json",
* });
* const customers = await response.json();
*
* return json({ customers });
* };
* ```
*
* ```ts
* // /app/shopify.server.ts
* import { shopifyApp } from "@shopify/shopify-app-remix/server";
* import { restResources } from "@shopify/shopify-api/rest/admin/2023-04";
*
* const shopify = shopifyApp({
* restResources,
* // ...etc
* });
* export default shopify;
* export const authenticate = shopify.authenticate;
* ```
*
* @example
* <caption>Performing a POST request to the REST API.</caption>
* <description>Use `admin.rest.post` to make custom requests to make a request to to the `customers.json` endpoint to send a welcome email</description>
* ```ts
* // /app/routes/**\/*.ts
* import { LoaderFunctionArgs, json } from "@remix-run/node";
* import { authenticate } from "../shopify.server";
*
* export const loader = async ({ request }: LoaderFunctionArgs) => {
* const {
* admin,
* session,
* } = await authenticate.admin(request);
*
* const response = admin.rest.post({
* path: "customers/7392136888625/send_invite.json",
* body: {
* customer_invite: {
* to: "new_test_email@shopify.com",
* from: "j.limited@example.com",
* bcc: ["j.limited@example.com"],
* subject: "Welcome to my new shop",
* custom_message: "My awesome new store",
* },
* },
* });
*
* const customerInvite = await response.json();
* return json({ customerInvite });
* };
* ```
*
* ```ts
* // /app/shopify.server.ts
* import { shopifyApp } from "@shopify/shopify-app-remix/server";
* import { restResources } from "@shopify/shopify-api/rest/admin/2023-04";
*
* const shopify = shopifyApp({
* restResources,
* // ...etc
* });
* export default shopify;
* export const authenticate = shopify.authenticate;
* ```
*/
rest: RestClientWithResources<Resources>;
/**
* Methods for interacting with the Shopify Admin GraphQL API
*
* {@link https://shopify.dev/docs/api/admin-graphql}
* {@link https://github.com/Shopify/shopify-app-js/blob/main/packages/apps/shopify-api/docs/reference/clients/Graphql.md}
*
* @example
* <caption>Querying the GraphQL API.</caption>
* <description>Use `admin.graphql` to make query / mutation requests.</description>
* ```ts
* // /app/routes/**\/*.ts
* import { ActionFunctionArgs } from "@remix-run/node";
* import { authenticate } from "../shopify.server";
*
* export const action = async ({ request }: ActionFunctionArgs) => {
* const { admin } = await authenticate.admin(request);
*
* const response = await admin.graphql(
* `#graphql
* mutation populateProduct($input: ProductInput!) {
* productCreate(input: $input) {
* product {
* id
* }
* }
* }`,
* {
* variables: {
* input: { title: "Product Name" },
* },
* },
* );
*
* const productData = await response.json();
* return json({
* productId: productData.data?.productCreate?.product?.id,
* });
* }
* ```
*
* ```ts
* // /app/shopify.server.ts
* import { shopifyApp } from "@shopify/shopify-app-remix/server";
*
* const shopify = shopifyApp({
* // ...
* });
* export default shopify;
* export const authenticate = shopify.authenticate;
* ```
*
* @example
* <caption>Handling GraphQL errors.</caption>
* <description>Catch `GraphqlQueryError` errors to see error messages from the API.</description>
* ```ts
* // /app/routes/**\/*.ts
* import { ActionFunctionArgs } from "@remix-run/node";
* import { authenticate } from "../shopify.server";
*
* export const action = async ({ request }: ActionFunctionArgs) => {
* const { admin } = await authenticate.admin(request);
*
* try {
* const response = await admin.graphql(
* `#graphql
* query incorrectQuery {
* products(first: 10) {
* nodes {
* not_a_field
* }
* }
* }`,
* );
*
* return json({ data: await response.json() });
* } catch (error) {
* if (error instanceof GraphqlQueryError) {
* // error.body.errors:
* // { graphQLErrors: [
* // { message: "Field 'not_a_field' doesn't exist on type 'Product'" }
* // ] }
* return json({ errors: error.body?.errors }, { status: 500 });
* }
* return json({ message: "An error occurred" }, { status: 500 });
* }
* }
* ```
*
* ```ts
* // /app/shopify.server.ts
* import { shopifyApp } from "@shopify/shopify-app-remix/server";
*
* const shopify = shopifyApp({
* // ...
* });
* export default shopify;
* export const authenticate = shopify.authenticate;
* ```
*/
graphql: GraphQLClient<AdminOperations>;
}
RestClientWithResources
RemixRestClient & {resources: Resources}
RemixRestClient
- session
Session
- get
Performs a GET request on the given path.
(params: GetRequestParams) => Promise<Response>
- post
Performs a POST request on the given path.
(params: PostRequestParams) => Promise<Response>
- put
Performs a PUT request on the given path.
(params: PostRequestParams) => Promise<Response>
- delete
Performs a DELETE request on the given path.
(params: GetRequestParams) => Promise<Response>
class RemixRestClient {
public session: Session;
private params: AdminClientOptions['params'];
private handleClientError: AdminClientOptions['handleClientError'];
constructor({params, session, handleClientError}: AdminClientOptions) {
this.params = params;
this.handleClientError = handleClientError;
this.session = session;
}
/**
* Performs a GET request on the given path.
*/
public async get(params: GetRequestParams) {
return this.makeRequest({
method: 'GET' as RequestParams['method'],
...params,
});
}
/**
* Performs a POST request on the given path.
*/
public async post(params: PostRequestParams) {
return this.makeRequest({
method: 'POST' as RequestParams['method'],
...params,
});
}
/**
* Performs a PUT request on the given path.
*/
public async put(params: PutRequestParams) {
return this.makeRequest({
method: 'PUT' as RequestParams['method'],
...params,
});
}
/**
* Performs a DELETE request on the given path.
*/
public async delete(params: DeleteRequestParams) {
return this.makeRequest({
method: 'DELETE' as RequestParams['method'],
...params,
});
}
protected async makeRequest(params: RequestParams): Promise<Response> {
const originalClient = new this.params.api.clients.Rest({
session: this.session,
});
const originalRequest = Reflect.get(originalClient, 'request');
try {
const apiResponse = await originalRequest.call(originalClient, params);
// We use a separate client for REST requests and REST resources because we want to override the API library
// client class to return a Response object instead.
return new Response(JSON.stringify(apiResponse.body), {
headers: apiResponse.headers,
});
} catch (error) {
if (this.handleClientError) {
throw await this.handleClientError({
error,
session: this.session,
params: this.params,
});
} else throw new Error(error);
}
}
}
GetRequestParams
- path
The path to the resource, relative to the API version root.
string
- type
The type of data expected in the response.
DataType
- data
The request body.
string | Record<string, any>
- query
Query parameters to be sent with the request.
SearchParams
- extraHeaders
Additional headers to be sent with the request.
HeaderParams
- tries
The maximum number of times the request can be made if it fails with a throttling or server error.
number
export interface GetRequestParams {
/**
* The path to the resource, relative to the API version root.
*/
path: string;
/**
* The type of data expected in the response.
*/
type?: DataType;
/**
* The request body.
*/
data?: Record<string, any> | string;
/**
* Query parameters to be sent with the request.
*/
query?: SearchParams;
/**
* Additional headers to be sent with the request.
*/
extraHeaders?: HeaderParams;
/**
* The maximum number of times the request can be made if it fails with a throttling or server error.
*/
tries?: number;
}
DataType
- JSON
application/json
- GraphQL
application/graphql
- URLEncoded
application/x-www-form-urlencoded
export enum DataType {
JSON = 'application/json',
GraphQL = 'application/graphql',
URLEncoded = 'application/x-www-form-urlencoded',
}
HeaderParams
Headers to be sent with the request.
Record<string, string | number | string[]>
PostRequestParams
GetRequestParams & {
data: Record<string, any> | string;
}
GraphQLClient
- query
Operation extends keyof Operations
- options
GraphQLQueryOptions<Operation, Operations>
interface Promise<T> {
/**
* Attaches callbacks for the resolution and/or rejection of the Promise.
* @param onfulfilled The callback to execute when the Promise is resolved.
* @param onrejected The callback to execute when the Promise is rejected.
* @returns A Promise for the completion of which ever callback is executed.
*/
then<TResult1 = T, TResult2 = never>(onfulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | undefined | null, onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | undefined | null): Promise<TResult1 | TResult2>;
/**
* Attaches a callback for only the rejection of the Promise.
* @param onrejected The callback to execute when the Promise is rejected.
* @returns A Promise for the completion of the callback.
*/
catch<TResult = never>(onrejected?: ((reason: any) => TResult | PromiseLike<TResult>) | undefined | null): Promise<T | TResult>;
}, interface Promise<T> {}, Promise: PromiseConstructor, interface Promise<T> {
readonly [Symbol.toStringTag]: string;
}, interface Promise<T> {
/**
* Attaches a callback that is invoked when the Promise is settled (fulfilled or rejected). The
* resolved value cannot be modified from the callback.
* @param onfinally The callback to execute when the Promise is settled (fulfilled or rejected).
* @returns A Promise for the completion of the callback.
*/
finally(onfinally?: (() => void) | undefined | null): Promise<T>;
}
export type GraphQLClient<Operations extends AllOperations> = <
Operation extends keyof Operations,
>(
query: Operation,
options?: GraphQLQueryOptions<Operation, Operations>,
) => Promise<GraphQLResponse<Operation, Operations>>;
GraphQLQueryOptions
- variables
The variables to pass to the operation.
ApiClientRequestOptions<Operation, Operations>["variables"]
- apiVersion
The version of the API to use for the request.
ApiVersion
- headers
Additional headers to include in the request.
Record<string, any>
- tries
The total number of times to try the request if it fails.
number
export interface GraphQLQueryOptions<
Operation extends keyof Operations,
Operations extends AllOperations,
> {
/**
* The variables to pass to the operation.
*/
variables?: ApiClientRequestOptions<Operation, Operations>['variables'];
/**
* The version of the API to use for the request.
*/
apiVersion?: ApiVersion;
/**
* Additional headers to include in the request.
*/
headers?: Record<string, any>;
/**
* The total number of times to try the request if it fails.
*/
tries?: number;
}
ApiVersion
- October22
2022-10
- January23
2023-01
- April23
2023-04
- July23
2023-07
- October23
2023-10
- January24
2024-01
- April24
2024-04
- Unstable
unstable
export enum ApiVersion {
October22 = '2022-10',
January23 = '2023-01',
April23 = '2023-04',
July23 = '2023-07',
October23 = '2023-10',
January24 = '2024-01',
April24 = '2024-04',
Unstable = 'unstable',
}
BillingContext
- require
Checks if the shop has an active payment for any plan defined in the `billing` config option.
(options: RequireBillingOptions<Config>) => Promise<BillingCheckResponseObject>
- check
Checks if the shop has an active payment for any plan defined in the `billing` config option.
(options: CheckBillingOptions<Config>) => Promise<BillingCheckResponseObject>
- request
Requests payment for the plan.
(options: RequestBillingOptions<Config>) => Promise<never>
- cancel
Cancels an ongoing subscription, given its ID.
(options: CancelBillingOptions) => Promise<AppSubscription>
export interface BillingContext<Config extends AppConfigArg> {
/**
* Checks if the shop has an active payment for any plan defined in the `billing` config option.
*
* @returns A promise that resolves to an object containing the active purchases for the shop.
*
* @example
* <caption>Requesting billing right away.</caption>
* <description>Call `billing.request` in the `onFailure` callback to immediately redirect to the Shopify page to request payment.</description>
* ```ts
* // /app/routes/**\/*.ts
* import { LoaderFunctionArgs } from "@remix-run/node";
* import { authenticate, MONTHLY_PLAN } from "../shopify.server";
*
* export const loader = async ({ request }: LoaderFunctionArgs) => {
* const { billing } = await authenticate.admin(request);
* await billing.require({
* plans: [MONTHLY_PLAN],
* isTest: true,
* onFailure: async () => billing.request({ plan: MONTHLY_PLAN }),
* });
*
* // App logic
* };
* ```
* ```ts
* // shopify.server.ts
* import { shopifyApp, BillingInterval } from "@shopify/shopify-app-remix/server";
*
* export const MONTHLY_PLAN = 'Monthly subscription';
* export const ANNUAL_PLAN = 'Annual subscription';
*
* const shopify = shopifyApp({
* // ...etc
* billing: {
* [MONTHLY_PLAN]: {
* amount: 5,
* currencyCode: 'USD',
* interval: BillingInterval.Every30Days,
* },
* [ANNUAL_PLAN]: {
* amount: 50,
* currencyCode: 'USD',
* interval: BillingInterval.Annual,
* },
* }
* });
* export default shopify;
* export const authenticate = shopify.authenticate;
* ```
*
* @example
* <caption>Redirect to a plan selection page.</caption>
* <description> When the app has multiple plans, create a page in your App that allows the merchant to select a plan. If a merchant does not have the required plan you can redirect them to page in your app to select one.</description>
* ```ts
* // /app/routes/**\/*.ts
* import { LoaderFunctionArgs, redirect } from "@remix-run/node";
* import { authenticate, MONTHLY_PLAN, ANNUAL_PLAN } from "../shopify.server";
*
* export const loader = async ({ request }: LoaderFunctionArgs) => {
* const { billing } = await authenticate.admin(request);
* const billingCheck = await billing.require({
* plans: [MONTHLY_PLAN, ANNUAL_PLAN],
* isTest: true,
* onFailure: () => redirect('/select-plan'),
* });
*
* const subscription = billingCheck.appSubscriptions[0];
* console.log(`Shop is on ${subscription.name} (id ${subscription.id})`);
*
* // App logic
* };
* ```
* ```ts
* // shopify.server.ts
* import { shopifyApp, BillingInterval } from "@shopify/shopify-app-remix/server";
*
* export const MONTHLY_PLAN = 'Monthly subscription';
* export const ANNUAL_PLAN = 'Annual subscription';
*
* const shopify = shopifyApp({
* // ...etc
* billing: {
* [MONTHLY_PLAN]: {
* amount: 5,
* currencyCode: 'USD',
* interval: BillingInterval.Every30Days,
* },
* [ANNUAL_PLAN]: {
* amount: 50,
* currencyCode: 'USD',
* interval: BillingInterval.Annual,
* },
* }
* });
* export default shopify;
* export const authenticate = shopify.authenticate;
* ```
* @example
* <caption>Requesting billing with line items</caption>
* <description>Call `billing.request` with the `v3_lineItemBilling` future flag enabled</description>
* ```ts
* // /app/routes/**\/*.ts
* import { LoaderFunctionArgs } from "@remix-run/node";
* import { authenticate, MONTHLY_PLAN } from "../shopify.server";
*
* export const loader = async ({ request }: LoaderFunctionArgs) => {
* const { billing } = await authenticate.admin(request);
* await billing.require({
* plans: [MONTHLY_PLAN],
* isTest: true,
* onFailure: async () => billing.request({ plan: MONTHLY_PLAN }),
* });
*
* // App logic
* };
* ```
* ```ts
* // shopify.server.ts
* import { shopifyApp, BillingInterval } from "@shopify/shopify-app-remix/server";
*
* export const MONTHLY_PLAN = 'Monthly subscription';
* export const ANNUAL_PLAN = 'Annual subscription';
*
* const shopify = shopifyApp({
* // ...etc
* billing: {
* [MONTHLY_PLAN]: {
* lineItems: [
* {
* amount: 5,
* currencyCode: 'USD',
* interval: BillingInterval.Every30Days,
* },
* {
* amount: 1,
* currencyCode: 'USD',
* interval: BillingInterval.Usage.
* terms: '1 dollar per 1000 emails',
* },
* ],
* },
* }
* future: {v3_lineItemBilling: true}
* });
* export default shopify;
* export const authenticate = shopify.authenticate;
* ```
*/
require: (
options: RequireBillingOptions<Config>,
) => Promise<BillingCheckResponseObject>;
/**
* Checks if the shop has an active payment for any plan defined in the `billing` config option.
*
* @returns A promise that resolves to an object containing the active purchases for the shop.
*
* @example
* <caption>Check what billing plans a merchant is subscribed to.</caption>
* <description>Use billing.check if you want to determine which plans are in use. Unlike `require`, `check` does not
* throw an error if no active billing plans are present. </description>
* ```ts
* // /app/routes/**\/*.ts
* import { LoaderFunctionArgs } from "@remix-run/node";
* import { authenticate, MONTHLY_PLAN } from "../shopify.server";
*
* export const loader = async ({ request }: LoaderFunctionArgs) => {
* const { billing } = await authenticate.admin(request);
* const { hasActivePayment, appSubscriptions } = await billing.check({
* plans: [MONTHLY_PLAN],
* isTest: false,
* });
* console.log(hasActivePayment)
* console.log(appSubscriptions)
* };
* ```
* ```ts
* // shopify.server.ts
* import { shopifyApp, BillingInterval } from "@shopify/shopify-app-remix/server";
*
* export const MONTHLY_PLAN = 'Monthly subscription';
* export const ANNUAL_PLAN = 'Annual subscription';
*
* const shopify = shopifyApp({
* // ...etc
* billing: {
* [MONTHLY_PLAN]: {
* amount: 5,
* currencyCode: 'USD',
* interval: BillingInterval.Every30Days,
* },
* [ANNUAL_PLAN]: {
* amount: 50,
* currencyCode: 'USD',
* interval: BillingInterval.Annual,
* },
* }
* });
* export default shopify;
* export const authenticate = shopify.authenticate;
* ```
*
*/
check: (
options: CheckBillingOptions<Config>,
) => Promise<BillingCheckResponseObject>;
/**
* Requests payment for the plan.
*
* @returns Redirects to the confirmation URL for the payment.
*
* @example
* <caption>Using a custom return URL.</caption>
* <description>Change where the merchant is returned to after approving the purchase using the `returnUrl` option.</description>
* ```ts
* // /app/routes/**\/*.ts
* import { LoaderFunctionArgs } from "@remix-run/node";
* import { authenticate, MONTHLY_PLAN } from "../shopify.server";
*
* export const loader = async ({ request }: LoaderFunctionArgs) => {
* const { billing } = await authenticate.admin(request);
* await billing.require({
* plans: [MONTHLY_PLAN],
* onFailure: async () => billing.request({
* plan: MONTHLY_PLAN,
* isTest: true,
* returnUrl: 'https://admin.shopify.com/store/my-store/apps/my-app/billing-page',
* }),
* });
*
* // App logic
* };
* ```
* ```ts
* // shopify.server.ts
* import { shopifyApp, BillingInterval } from "@shopify/shopify-app-remix/server";
*
* export const MONTHLY_PLAN = 'Monthly subscription';
* export const ANNUAL_PLAN = 'Annual subscription';
*
* const shopify = shopifyApp({
* // ...etc
* billing: {
* [MONTHLY_PLAN]: {
* amount: 5,
* currencyCode: 'USD',
* interval: BillingInterval.Every30Days,
* },
* [ANNUAL_PLAN]: {
* amount: 50,
* currencyCode: 'USD',
* interval: BillingInterval.Annual,
* },
* }
* });
* export default shopify;
* export const authenticate = shopify.authenticate;
* ```
*/
request: (options: RequestBillingOptions<Config>) => Promise<never>;
/**
* Cancels an ongoing subscription, given its ID.
*
* @returns The cancelled subscription.
*
* @example
* <caption>Cancelling a subscription.</caption>
* <description>Use the `billing.cancel` function to cancel an active subscription with the id returned from `billing.require`.</description>
* ```ts
* // /app/routes/cancel-subscription.ts
* import { LoaderFunctionArgs } from "@remix-run/node";
* import { authenticate, MONTHLY_PLAN } from "../shopify.server";
*
* export const loader = async ({ request }: LoaderFunctionArgs) => {
* const { billing } = await authenticate.admin(request);
* const billingCheck = await billing.require({
* plans: [MONTHLY_PLAN],
* onFailure: async () => billing.request({ plan: MONTHLY_PLAN }),
* });
*
* const subscription = billingCheck.appSubscriptions[0];
* const cancelledSubscription = await billing.cancel({
* subscriptionId: subscription.id,
* isTest: true,
* prorate: true,
* });
*
* // App logic
* };
* ```
* ```ts
* // shopify.server.ts
* import { shopifyApp, BillingInterval } from "@shopify/shopify-app-remix/server";
*
* export const MONTHLY_PLAN = 'Monthly subscription';
* export const ANNUAL_PLAN = 'Annual subscription';
*
* const shopify = shopifyApp({
* // ...etc
* billing: {
* [MONTHLY_PLAN]: {
* amount: 5,
* currencyCode: 'USD',
* interval: BillingInterval.Every30Days,
* },
* [ANNUAL_PLAN]: {
* amount: 50,
* currencyCode: 'USD',
* interval: BillingInterval.Annual,
* },
* }
* });
* export default shopify;
* export const authenticate = shopify.authenticate;
* ```
*/
cancel: (options: CancelBillingOptions) => Promise<AppSubscription>;
}
RequireBillingOptions
- plans
The plans to check for. Must be one of the values defined in the `billing` config option.
(keyof Config["billing"])[]
- onFailure
How to handle the request if the shop doesn't have an active payment for any plan.
(error: any) => Promise<Response>
- isTest
Whether to consider test purchases.
boolean
export interface RequireBillingOptions<Config extends AppConfigArg>
extends Omit<BillingCheckParams, 'session' | 'plans' | 'returnObject'> {
/**
* The plans to check for. Must be one of the values defined in the `billing` config option.
*/
plans: (keyof Config['billing'])[];
/**
* How to handle the request if the shop doesn't have an active payment for any plan.
*/
onFailure: (error: any) => Promise<Response>;
}
BillingCheckResponseObject
- hasActivePayment
Whether the user has an active payment method.
boolean
- oneTimePurchases
The one-time purchases the shop has.
OneTimePurchase[]
- appSubscriptions
The active subscriptions the shop has.
AppSubscription[]
export interface BillingCheckResponseObject {
/**
* Whether the user has an active payment method.
*/
hasActivePayment: boolean;
/**
* The one-time purchases the shop has.
*/
oneTimePurchases: OneTimePurchase[];
/**
* The active subscriptions the shop has.
*/
appSubscriptions: AppSubscription[];
}
OneTimePurchase
- id
The ID of the one-time purchase.
string
- name
The name of the purchased plan.
string
- test
Whether this is a test purchase.
boolean
- status
The status of the one-time purchase.
string
export interface OneTimePurchase {
/**
* The ID of the one-time purchase.
*/
id: string;
/**
* The name of the purchased plan.
*/
name: string;
/**
* Whether this is a test purchase.
*/
test: boolean;
/**
* The status of the one-time purchase.
*/
status: string;
}
AppSubscription
- id
The ID of the app subscription.
string
- name
The name of the purchased plan.
string
- test
Whether this is a test subscription.
boolean
- lineItems
ActiveSubscriptionLineItem[]
export interface AppSubscription {
/**
* The ID of the app subscription.
*/
id: string;
/**
* The name of the purchased plan.
*/
name: string;
/**
* Whether this is a test subscription.
*/
test: boolean;
/*
* The line items for this plan. This will become mandatory in v10.
*/
lineItems?: ActiveSubscriptionLineItem[];
}
ActiveSubscriptionLineItem
- id
string
- plan
AppPlan
export interface ActiveSubscriptionLineItem {
/*
* The ID of the line item.
*/
id: string;
/*
* The details of the plan.
*/
plan: AppPlan;
}
AppPlan
- pricingDetails
RecurringAppPlan | UsageAppPlan
export interface AppPlan {
/*
* The pricing details of the plan.
*/
pricingDetails: RecurringAppPlan | UsageAppPlan;
}
RecurringAppPlan
- interval
BillingInterval.Every30Days | BillingInterval.Annual
- price
Money
- discount
AppPlanDiscount
export interface RecurringAppPlan {
/*
* The interval for this plan is charged on.
*/
interval: BillingInterval.Every30Days | BillingInterval.Annual;
/*
* The price of the plan.
*/
price: Money;
/*
* The discount applied to the plan.
*/
discount: AppPlanDiscount;
}
BillingInterval
- OneTime
ONE_TIME
- Every30Days
EVERY_30_DAYS
- Annual
ANNUAL
- Usage
USAGE
export enum BillingInterval {
OneTime = 'ONE_TIME',
Every30Days = 'EVERY_30_DAYS',
Annual = 'ANNUAL',
Usage = 'USAGE',
}
Money
- amount
number
- currencyCode
string
interface Money {
amount: number;
currencyCode: string;
}
AppPlanDiscount
- durationLimitInIntervals
number
- remainingDurationInIntervals
number
- priceAfterDiscount
Money
- value
AppPlanDiscountAmount
export interface AppPlanDiscount {
/*
* The total number of intervals the discount applies to.
*/
durationLimitInIntervals: number;
/*
* The remaining number of intervals the discount applies to.
*/
remainingDurationInIntervals: number;
/*
* The price after the discount is applied.
*/
priceAfterDiscount: Money;
/*
* The value of the discount applied every billing interval.
*/
value: AppPlanDiscountAmount;
}
AppPlanDiscountAmount
BillingConfigSubscriptionPlanDiscountAmount | BillingConfigSubscriptionPlanDiscountPercentage
BillingConfigSubscriptionPlanDiscountAmount
- amount
The amount to discount. Cannot be set if `percentage` is set.
number
- percentage
The percentage to discount. Cannot be set if `amount` is set.
never
export interface BillingConfigSubscriptionPlanDiscountAmount {
/**
* The amount to discount.
*
* Cannot be set if `percentage` is set.
*/
amount: number;
/**
* The percentage to discount.
*
* Cannot be set if `amount` is set.
*/
percentage?: never;
}
BillingConfigSubscriptionPlanDiscountPercentage
- amount
The amount to discount. Cannot be set if `percentage` is set.
never
- percentage
The percentage to discount. Cannot be set if `amount` is set.
number
export interface BillingConfigSubscriptionPlanDiscountPercentage {
/**
* The amount to discount.
*
* Cannot be set if `percentage` is set.
*/
amount?: never;
/**
* The percentage to discount.
*
* Cannot be set if `amount` is set.
*/
percentage: number;
}
UsageAppPlan
- balanceUsed
Money
- cappedAmount
Money
- terms
string
export interface UsageAppPlan {
/*
* The total usage records for interval.
*/
balanceUsed: Money;
/*
* The capped amount prevents the merchant from being charged for any usage over that amount during a billing period.
*/
cappedAmount: Money;
/*
* The terms and conditions for app usage pricing.
*/
terms: string;
}
CheckBillingOptions
- plans
The plans to check for. Must be one of the values defined in the `billing` config option.
(keyof Config["billing"])[]
- isTest
Whether to consider test purchases.
boolean
export interface CheckBillingOptions<Config extends AppConfigArg>
extends Omit<BillingCheckParams, 'session' | 'plans' | 'returnObject'> {
/**
* The plans to check for. Must be one of the values defined in the `billing` config option.
*/
plans: (keyof Config['billing'])[];
}
RequestBillingOptions
- plan
The plan to request. Must be one of the values defined in the `billing` config option.
keyof Config["billing"]
- isTest
Whether to use the test mode. This prevents the credit card from being charged. Test shops and demo shops cannot be charged.
boolean
- returnUrl
The URL to return to after the merchant approves the payment.
string
export interface RequestBillingOptions<Config extends AppConfigArg>
extends Omit<BillingRequestParams, 'session' | 'plan' | 'returnObject'> {
/**
* The plan to request. Must be one of the values defined in the `billing` config option.
*/
plan: keyof Config['billing'];
/**
* Whether to use the test mode. This prevents the credit card from being charged. Test shops and demo shops cannot be charged.
*/
isTest?: boolean;
/**
* The URL to return to after the merchant approves the payment.
*/
returnUrl?: string;
}
CancelBillingOptions
- subscriptionId
The ID of the subscription to cancel.
string
- prorate
Whether to prorate the cancellation.
boolean
- isTest
boolean
export interface CancelBillingOptions {
/**
* The ID of the subscription to cancel.
*/
subscriptionId: string;
/**
* Whether to prorate the cancellation.
*
* {@link https://shopify.dev/docs/apps/billing/subscriptions/cancel-recurring-charges}
*/
prorate?: boolean;
/*
* Whether to use the test mode. This prevents the credit card from being charged. Test shops and demo shops cannot be charged.
*/
isTest?: boolean;
}
EnsureCORSFunction
export interface EnsureCORSFunction {
(response: Response): Response;
}
EmbeddedAdminContext
- sessionToken
The decoded and validated session token for the request. Returned only if `isEmbeddedApp` is `true`.
JwtPayload
- redirect
A function that redirects the user to a new page, ensuring that the appropriate parameters are set for embedded apps. Returned only if `isEmbeddedApp` is `true`.
RedirectFunction
- session
The session for the user who made the request. This comes from the session storage which `shopifyApp` uses to store sessions in your database of choice. Use this to get shop or user-specific data.
Session
- admin
Methods for interacting with the GraphQL / REST Admin APIs for the store that made the request.
AdminApiContext<Resources>
- billing
Billing methods for this store, based on the plans defined in the `billing` config option.
BillingContext<Config>
- cors
A function that ensures the CORS headers are set correctly for the response.
EnsureCORSFunction
export interface EmbeddedAdminContext<
Config extends AppConfigArg,
Resources extends ShopifyRestResources = ShopifyRestResources,
> extends AdminContextInternal<Config, Resources> {
/**
* The decoded and validated session token for the request.
*
* Returned only if `isEmbeddedApp` is `true`.
*
* {@link https://shopify.dev/docs/apps/auth/oauth/session-tokens#payload}
*
* @example
* <caption>Using the decoded session token.</caption>
* <description>Get user-specific data using the `sessionToken` object.</description>
* ```ts
* // /app/routes/**\/*.ts
* import { LoaderFunctionArgs, json } from "@remix-run/node";
* import { authenticate } from "../shopify.server";
* import { getMyAppData } from "~/db/model.server";
*
* export const loader = async ({ request }: LoaderFunctionArgs) => {
* const { sessionToken } = await authenticate.admin(
* request
* );
* return json(await getMyAppData({user: sessionToken.sub}));
* };
* ```
* ```ts
* // shopify.server.ts
* import { shopifyApp } from "@shopify/shopify-app-remix/server";
*
* const shopify = shopifyApp({
* // ...etc
* useOnlineTokens: true,
* });
* export default shopify;
* export const authenticate = shopify.authenticate;
* ```
*/
sessionToken: JwtPayload;
/**
* A function that redirects the user to a new page, ensuring that the appropriate parameters are set for embedded
* apps.
*
* Returned only if `isEmbeddedApp` is `true`.
*
* @example
* <caption>Redirecting to an app route.</caption>
* <description>Use the `redirect` helper to safely redirect between pages.</description>
* ```ts
* // /app/routes/admin/my-route.ts
* import { LoaderFunctionArgs, json } from "@remix-run/node";
* import { authenticate } from "../shopify.server";
*
* export const loader = async ({ request }: LoaderFunctionArgs) => {
* const { session, redirect } = await authenticate.admin(request);
* return redirect("/");
* };
* ```
*
* @example
* <caption>Redirecting outside of Shopify admin.</caption>
* <description>Pass in a `target` option of `_top` or `_parent` to go to an external URL.</description>
* ```ts
* // /app/routes/admin/my-route.ts
* import { LoaderFunctionArgs, json } from "@remix-run/node";
* import { authenticate } from "../shopify.server";
*
* export const loader = async ({ request }: LoaderFunctionArgs) => {
* const { session, redirect } = await authenticate.admin(request);
* return redirect("/", { target: '_parent' });
* };
* ```
*/
redirect: RedirectFunction;
}
JwtPayload
- iss
The shop's admin domain.
string
- dest
The shop's domain.
string
- aud
The client ID of the receiving app.
string
- sub
The User that the session token is intended for.
string
- exp
When the session token expires.
number
- nbf
When the session token activates.
number
- iat
When the session token was issued.
number
- jti
A secure random UUID.
string
- sid
A unique session ID per user and app.
string
export interface JwtPayload {
/**
* The shop's admin domain.
*/
iss: string;
/**
* The shop's domain.
*/
dest: string;
/**
* The client ID of the receiving app.
*/
aud: string;
/**
* The User that the session token is intended for.
*/
sub: string;
/**
* When the session token expires.
*/
exp: number;
/**
* When the session token activates.
*/
nbf: number;
/**
* When the session token was issued.
*/
iat: number;
/**
* A secure random UUID.
*/
jti: string;
/**
* A unique session ID per user and app.
*/
sid: string;
}
RedirectFunction
- url
string
- init
RedirectInit
TypedResponse<never>
export type RedirectFunction = (
url: string,
init?: RedirectInit,
) => TypedResponse<never>;
RedirectInit
number | (ResponseInit & {target?: RedirectTarget})
RedirectTarget
'_self' | '_parent' | '_top'
LegacyWebhookAdminApiContext
- rest
A REST client.
RestClient & Resources
- graphql
A GraphQL client.
InstanceType<Shopify['clients']['Graphql']>
export interface LegacyWebhookAdminApiContext<
Resources extends ShopifyRestResources,
> {
/** A REST client. */
rest: InstanceType<Shopify['clients']['Rest']> & Resources;
/** A GraphQL client. */
graphql: InstanceType<Shopify['clients']['Graphql']>;
}
RestClient
- loggedDeprecations
Record<string, number>
- client
AdminRestApiClient
- session
Session
- apiVersion
ApiVersion
- get
Performs a GET request on the given path.
<T = any>(params: GetRequestParams) => Promise<RestRequestReturn<T>>
- post
Performs a POST request on the given path.
<T = any>(params: PostRequestParams) => Promise<RestRequestReturn<T>>
- put
Performs a PUT request on the given path.
<T = any>(params: PostRequestParams) => Promise<RestRequestReturn<T>>
- delete
Performs a DELETE request on the given path.
<T = any>(params: GetRequestParams) => Promise<RestRequestReturn<T>>
export class RestClient {
public static config: ConfigInterface;
public static formatPaths: boolean;
static LINK_HEADER_REGEXP = /<([^<]+)>; rel="([^"]+)"/;
static DEFAULT_LIMIT = '50';
static RETRY_WAIT_TIME = 1000;
static readonly DEPRECATION_ALERT_DELAY = 300000;
loggedDeprecations: Record<string, number> = {};
readonly client: AdminRestApiClient;
readonly session: Session;
readonly apiVersion: ApiVersion;
public constructor({session, apiVersion}: RestClientParams) {
const config = this.restClass().config;
if (!config.isCustomStoreApp && !session.accessToken) {
throw new ShopifyErrors.MissingRequiredArgument(
'Missing access token when creating REST client',
);
}
if (apiVersion) {
const message =
apiVersion === config.apiVersion
? `REST client has a redundant API version override to the default ${apiVersion}`
: `REST client overriding default API version ${config.apiVersion} with ${apiVersion}`;
logger(config).debug(message);
}
const customStoreAppAccessToken =
config.adminApiAccessToken ?? config.apiSecretKey;
this.session = session;
this.apiVersion = apiVersion ?? config.apiVersion;
this.client = createAdminRestApiClient({
scheme: config.hostScheme,
storeDomain: session.shop,
apiVersion: apiVersion ?? config.apiVersion,
accessToken: config.isCustomStoreApp
? customStoreAppAccessToken
: session.accessToken!,
customFetchApi: abstractFetch,
logger: clientLoggerFactory(config),
userAgentPrefix: getUserAgent(config),
defaultRetryTime: this.restClass().RETRY_WAIT_TIME,
formatPaths: this.restClass().formatPaths,
});
}
/**
* Performs a GET request on the given path.
*/
public async get<T = any>(params: GetRequestParams) {
return this.request<T>({method: Method.Get, ...params});
}
/**
* Performs a POST request on the given path.
*/
public async post<T = any>(params: PostRequestParams) {
return this.request<T>({method: Method.Post, ...params});
}
/**
* Performs a PUT request on the given path.
*/
public async put<T = any>(params: PutRequestParams) {
return this.request<T>({method: Method.Put, ...params});
}
/**
* Performs a DELETE request on the given path.
*/
public async delete<T = any>(params: DeleteRequestParams) {
return this.request<T>({method: Method.Delete, ...params});
}
protected async request<T = any>(
params: RequestParams,
): Promise<RestRequestReturn<T>> {
const requestParams = {
headers: {
...params.extraHeaders,
...(params.type ? {'Content-Type': params.type.toString()} : {}),
},
retries: params.tries ? params.tries - 1 : undefined,
searchParams: params.query,
};
let response: Response;
switch (params.method) {
case Method.Get:
response = await this.client.get(params.path, requestParams);
break;
case Method.Put:
response = await this.client.put(params.path, {
...requestParams,
data: params.data!,
});
break;
case Method.Post:
response = await this.client.post(params.path, {
...requestParams,
data: params.data!,
});
break;
case Method.Delete:
response = await this.client.delete(params.path, requestParams);
break;
default:
throw new ShopifyErrors.InvalidRequestError(
`Unsupported request method '${params.method}'`,
);
}
const body: any = await response.json();
const responseHeaders = canonicalizeHeaders(
Object.fromEntries(response.headers.entries()),
);
if (!response.ok) {
throwFailedRequest(body, (params.tries ?? 1) > 1, response);
}
const requestReturn: RestRequestReturn<T> = {
body,
headers: responseHeaders,
};
await this.logDeprecations(
{
method: params.method,
url: params.path,
headers: requestParams.headers,
body: params.data ? JSON.stringify(params.data) : undefined,
},
requestReturn,
);
const link = response.headers.get('Link');
if (link !== undefined) {
const pageInfo: PageInfo = {
limit: params.query?.limit
? params.query?.limit.toString()
: RestClient.DEFAULT_LIMIT,
};
if (link) {
const links = link.split(', ');
for (const link of links) {
const parsedLink = link.match(RestClient.LINK_HEADER_REGEXP);
if (!parsedLink) {
continue;
}
const linkRel = parsedLink[2];
const linkUrl = new URL(parsedLink[1]);
const linkFields = linkUrl.searchParams.get('fields');
const linkPageToken = linkUrl.searchParams.get('page_info');
if (!pageInfo.fields && linkFields) {
pageInfo.fields = linkFields.split(',');
}
if (linkPageToken) {
switch (linkRel) {
case 'previous':
pageInfo.previousPageUrl = parsedLink[1];
pageInfo.prevPage = this.buildRequestParams(parsedLink[1]);
break;
case 'next':
pageInfo.nextPageUrl = parsedLink[1];
pageInfo.nextPage = this.buildRequestParams(parsedLink[1]);
break;
}
}
}
}
requestReturn.pageInfo = pageInfo;
}
return requestReturn;
}
private restClass() {
return this.constructor as typeof RestClient;
}
private buildRequestParams(newPageUrl: string): PageInfoParams {
const pattern = `^/admin/api/[^/]+/(.*).json$`;
const url = new URL(newPageUrl);
const path = url.pathname.replace(new RegExp(pattern), '$1');
return {
path,
query: Object.fromEntries(url.searchParams.entries()),
};
}
private async logDeprecations(
request: NormalizedRequest,
response: RestRequestReturn,
) {
const config = this.restClass().config;
const deprecationReason = getHeader(
response.headers,
'X-Shopify-API-Deprecated-Reason',
);
if (deprecationReason) {
const deprecation: DeprecationInterface = {
message: deprecationReason,
path: request.url,
};
if (request.body) {
// This can only be a string, since we're always converting the body before calling this method
deprecation.body = `${(request.body as string).substring(0, 100)}...`;
}
const depHash = await createSHA256HMAC(
config.apiSecretKey,
JSON.stringify(deprecation),
HashFormat.Hex,
);
if (
!Object.keys(this.loggedDeprecations).includes(depHash) ||
Date.now() - this.loggedDeprecations[depHash] >=
RestClient.DEPRECATION_ALERT_DELAY
) {
this.loggedDeprecations[depHash] = Date.now();
const stack = new Error().stack;
const message = `API Deprecation Notice ${new Date().toLocaleString()} : ${JSON.stringify(
deprecation,
)} - Stack Trace: ${stack}`;
await logger(config).warning(message);
}
}
}
}
RestRequestReturn
- body
T
- headers
Headers
- pageInfo
PageInfo
export interface RestRequestReturn<T = any> {
body: T;
headers: Headers;
pageInfo?: PageInfo;
}
Headers
Record<string, string | string[]>
PageInfo
- limit
string
- fields
string[]
- previousPageUrl
string
- nextPageUrl
string
- prevPage
PageInfoParams
- nextPage
PageInfoParams
export interface PageInfo {
limit: string;
fields?: string[];
previousPageUrl?: string;
nextPageUrl?: string;
prevPage?: PageInfoParams;
nextPage?: PageInfoParams;
}
PageInfoParams
- path
string
- query
SearchParams
export interface PageInfoParams {
path: string;
query: SearchParams;
}
Update a metafield when a product is updated
/app/routes/**.ts
examples
Update a metafield when a product is updated
description
Update a metafield when a product is updated
/app/routes/**.ts
import {type ActionFunctionArgs} from '@remix-run/node'; import {authenticate} from '../shopify.server'; export const action = async ({request}: ActionFunctionArgs) => { const {topic, admin, payload} = await authenticate.webhook(request); switch (topic) { case 'PRODUCTS_UPDATE': await admin.graphql( `#graphql mutation setMetafield($productId: ID!, $time: String!) { metafieldsSet(metafields: { ownerId: $productId namespace: "my-app", key: "webhook_received_at", value: $time, type: "string", }) { metafields { key value } } } `, { variables: { productId: payload.admin_graphql_api_id, time: new Date().toISOString(), }, }, ); return new Response(); } throw new Response(); };
Anchor to examplesExamples
Anchor to example-[v3]-webhook-admin-context[V3] Webhook admin context
With the future flag enabled, use the
admin
object in the context to interact with the Admin API.
Anchor to example-webhook-admin-contextWebhook admin context
Use the admin
object in the context to interact with the Admin API. This format will be removed in V3 of the package.
[V3] Webhook admin context
/app/routes/webhooks.tsx
examples
[V3] Webhook admin context
description
With the `v3_webhookAdminContext` future flag enabled, use the `admin` object in the context to interact with the Admin API.
/app/routes/webhooks.tsx
import { ActionFunctionArgs } from "@remix-run/node"; import { authenticate } from "../shopify.server"; export async function action({ request }: ActionFunctionArgs) { const { admin } = await authenticate.webhook(request); const response = await admin?.graphql( `#graphql mutation populateProduct($input: ProductInput!) { productCreate(input: $input) { product { id } } }`, { variables: { input: { title: "Product Name" } } } ); const productData = await response.json(); return json({ data: productData.data }); }
Webhook admin context
description
Use the `admin` object in the context to interact with the Admin API. This format will be removed in V3 of the package.
/app/routes/webhooks.tsx
import { json, ActionFunctionArgs } from "@remix-run/node"; import { authenticate } from "../shopify.server"; export async function action({ request }: ActionFunctionArgs) { const { admin } = await authenticate.webhook(request); const response = await admin?.graphql.query<any>({ data: { query: `#graphql mutation populateProduct($input: ProductInput!) { productCreate(input: $input) { product { id } } }`, variables: { input: { title: "Product Name" } }, }, }); const productData = response?.body.data; return json({ data: productData.data }); }
Anchor to example-apiversionapiVersion
Anchor to example-webhook-api-versionWebhook API version
Get the API version used for webhook request.
Webhook API version
/app/routes/webhooks.tsx
examples
Webhook API version
description
Get the API version used for webhook request.
/app/routes/webhooks.tsx
import { ActionFunctionArgs } from "@remix-run/node"; import { authenticate } from "../shopify.server"; export const action = async ({ request }: ActionFunctionArgs) => { const { apiVersion } = await authenticate.webhook(request); return new Response(); };
Anchor to example-webhook-shopWebhook shop
Get the shop that triggered a webhook.
Webhook shop
/app/routes/webhooks.tsx
examples
Webhook shop
description
Get the shop that triggered a webhook.
/app/routes/webhooks.tsx
import { ActionFunctionArgs } from "@remix-run/node"; import { authenticate } from "../shopify.server"; export const action = async ({ request }: ActionFunctionArgs) => { const { shop } = await authenticate.webhook(request); return new Response(); };
Anchor to example-webhook-topicWebhook topic
Get the event topic for the webhook.
Webhook topic
/app/routes/webhooks.tsx
examples
Webhook topic
description
Get the event topic for the webhook.
/app/routes/webhooks.tsx
import { ActionFunctionArgs } from "@remix-run/node"; import { authenticate } from "../shopify.server"; export const action = async ({ request }: ActionFunctionArgs) => { const { topic } = await authenticate.webhook(request); switch (topic) { case "APP_UNINSTALLED": // Do something when the app is uninstalled. break; } return new Response(); };
Anchor to example-webhookidwebhookId
Anchor to example-webhook-idWebhook ID
Get the webhook ID.
Webhook ID
/app/routes/webhooks.tsx
examples
Webhook ID
description
Get the webhook ID.
/app/routes/webhooks.tsx
import { ActionFunctionArgs } from "@remix-run/node"; import { authenticate } from "../shopify.server"; export const action = async ({ request }: ActionFunctionArgs) => { const { webhookId } = await authenticate.webhook(request); return new Response(); };
Anchor to example-payloadpayload
Anchor to example-webhook-payloadWebhook payload
Get the request's POST payload.
Webhook payload
/app/routes/webhooks.tsx
examples
Webhook payload
description
Get the request's POST payload.
/app/routes/webhooks.tsx
import { ActionFunctionArgs } from "@remix-run/node"; import { authenticate } from "../shopify.server"; export const action = async ({ request }: ActionFunctionArgs) => { const { payload } = await authenticate.webhook(request); return new Response(); };
Anchor to example-subtopicsubTopic
Anchor to example-webhook-sub-topicWebhook sub-topic
Get the webhook sub-topic.
Webhook sub-topic
/app/routes/webhooks.tsx
examples
Webhook sub-topic
description
Get the webhook sub-topic.
/app/routes/webhooks.tsx
import { ActionFunctionArgs } from "@remix-run/node"; import { authenticate } from "../shopify.server"; export const action = async ({ request }: ActionFunctionArgs) => { const { subTopic } = await authenticate.webhook(request); return new Response(); };