--- title: Webhook description: >- Contains functions for verifying Shopify webhooks. > Note: The format of the `admin` object returned by this function changes with the `v3_webhookAdminContext` future flag. Learn more about [gradual feature adoption](/docs/api/shopify-app-remix/guide-future-flags). api_version: v2 api_name: shopify-app-remix source_url: html: 'https://shopify.dev/docs/api/shopify-app-remix/v2/authenticate/webhook' md: 'https://shopify.dev/docs/api/shopify-app-remix/v2/authenticate/webhook.md' --- # Webhookobject Contains functions for verifying Shopify webhooks. Note The format of the `admin` object returned by this function changes with the `v3_webhookAdminContext` future flag. Learn more about [gradual feature adoption](https://shopify.dev/docs/api/shopify-app-remix/guide-future-flags). ## authenticate.​webhook([request](#authenticatewebhook-propertydetail-request)​) Verifies requests coming from Shopify webhooks. ### Parameters * request Request required ### Returns * Promise\> ### WebhookContext ```ts WebhookContextWithoutSession | WebhookContextWithSession ``` ### WebhookContextWithoutSession * session ```ts undefined ``` * admin ```ts undefined ``` * apiVersion The API version used for the webhook. ```ts string ``` * shop The shop where the webhook was triggered. ```ts string ``` * topic The topic of the webhook. ```ts Topics ``` * webhookId A unique ID for the webhook. Useful to keep track of which events your app has already processed. ```ts string ``` * payload The payload from the webhook request. ```ts Record ``` * subTopic The sub-topic of the webhook. This is only available for certain webhooks. ```ts string ``` ```ts export interface WebhookContextWithoutSession extends Context { 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. ```ts Session ``` * admin An admin context for the webhook. Returned only if there is a session for the shop. ```ts WebhookAdminContext ``` * apiVersion The API version used for the webhook. ```ts string ``` * shop The shop where the webhook was triggered. ```ts string ``` * topic The topic of the webhook. ```ts Topics ``` * webhookId A unique ID for the webhook. Useful to keep track of which events your app has already processed. ```ts string ``` * payload The payload from the webhook request. ```ts Record ``` * subTopic The sub-topic of the webhook. This is only available for certain webhooks. ```ts string ``` ````ts export interface WebhookContextWithSession< Future extends FutureFlagOptions, Resources extends ShopifyRestResources, Topics = string | number | symbol, > extends Context { /** * 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 * [V3] Webhook admin context. * With the `v3_webhookAdminContext` future flag enabled, use the `admin` object in the context to interact with the Admin API. * ```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 * Webhook 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. * ```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({ * 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; } ```` ### 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. ```ts string ``` * shop The Shopify shop domain, such as \`example.myshopify.com\`. ```ts string ``` * state The state of the session. Used for the OAuth authentication code flow. ```ts string ``` * isOnline Whether the access token in the session is online or offline. ```ts boolean ``` * scope The desired scopes for the access token, at the time the session was created. ```ts string ``` * expires The date the access token expires. ```ts Date ``` * accessToken The access token for the session. ```ts string ``` * onlineAccessInfo Information on the user for the session. Only present for online sessions. ```ts OnlineAccessInfo ``` * isActive Whether the session is active. Active sessions have an access token that is not expired, and has the given scopes. ```ts (scopes: string | string[] | AuthScopes) => boolean ``` * isScopeChanged Whether the access token has the given scopes. ```ts (scopes: string | string[] | AuthScopes) => boolean ``` * isExpired Whether the access token is expired. ```ts (withinMillisecondsOfExpiry?: number) => boolean ``` * toObject Converts an object with data into a Session. ```ts () => SessionParams ``` * equals Checks whether the given session is equal to this session. ```ts (other: Session) => boolean ``` * toPropertyArray Converts the session into an array of key-value pairs. ```ts (returnUserData?: boolean) => [string, string | number | boolean][] ``` ```ts 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. ```ts number ``` * associated\_user\_scope The effective set of scopes for the session. ```ts string ``` * associated\_user The user associated with the access token. ```ts OnlineAccessUser ``` ```ts 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. ```ts number ``` * first\_name The user's first name. ```ts string ``` * last\_name The user's last name. ```ts string ``` * email The user's email address. ```ts string ``` * email\_verified Whether the user has verified their email address. ```ts boolean ``` * account\_owner Whether the user is the account owner. ```ts boolean ``` * locale The user's locale. ```ts string ``` * collaborator Whether the user is a collaborator. ```ts boolean ``` ```ts 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. ```ts (scope: string | string[] | AuthScopes) => boolean ``` * equals Checks whether the current set of scopes equals the given one. ```ts (otherScopes: string | string[] | AuthScopes) => boolean ``` * toString Returns a comma-separated string with the current set of scopes. ```ts () => string ``` * toArray Returns an array with the current set of scopes. ```ts () => any[] ``` ```ts class AuthScopes { public static SCOPE_DELIMITER = ','; private compressedScopes: Set; private expandedScopes: Set; 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] ```ts any ``` * id The unique identifier for the session. ```ts string ``` * shop The Shopify shop domain. ```ts string ``` * state The state of the session. Used for the OAuth authentication code flow. ```ts string ``` * isOnline Whether the access token in the session is online or offline. ```ts boolean ``` * scope The scopes for the access token. ```ts string ``` * expires The date the access token expires. ```ts Date ``` * accessToken The access token for the session. ```ts string ``` * onlineAccessInfo Information on the user for the session. Only present for online sessions. ```ts OnlineAccessInfo | StoredOnlineAccessInfo ``` ```ts 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 ```ts Omit & { associated_user: Partial; } ``` ### WebhookAdminContext ```ts FeatureEnabled extends true ? AdminApiContext : LegacyWebhookAdminApiContext ``` ### 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. ```ts RestClientWithResources ``` * graphql Methods for interacting with the Shopify Admin GraphQL API ```ts GraphQLClient ``` ````ts 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 * Using REST resources. * 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. * * ```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 * Performing a GET request to the REST API. * Use `admin.rest.get` to make custom requests to make a request to to the `customer/count` endpoint * * ```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 * Performing a POST request to the REST API. * Use `admin.rest.post` to make custom requests to make a request to to the `customers.json` endpoint to send a welcome email * ```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; /** * 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 * Querying the GraphQL API. * Use `admin.graphql` to make query / mutation requests. * ```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 * Handling GraphQL errors. * Catch `GraphqlQueryError` errors to see error messages from the API. * ```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; } ```` ### RestClientWithResources ```ts RemixRestClient & {resources: Resources} ``` ### RemixRestClient * session ```ts Session ``` * get Performs a GET request on the given path. ```ts (params: GetRequestParams) => Promise ``` * post Performs a POST request on the given path. ```ts (params: PostRequestParams) => Promise ``` * put Performs a PUT request on the given path. ```ts (params: PostRequestParams) => Promise ``` * delete Performs a DELETE request on the given path. ```ts (params: GetRequestParams) => Promise ``` ```ts 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 { 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. ```ts string ``` * type The type of data expected in the response. ```ts DataType ``` * data The request body. ```ts string | Record ``` * query Query parameters to be sent with the request. ```ts SearchParams ``` * extraHeaders Additional headers to be sent with the request. ```ts HeaderParams ``` * tries The maximum number of times the request can be made if it fails with a throttling or server error. ```ts number ``` ```ts 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; /** * 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 ```ts application/json ``` * GraphQL ```ts application/graphql ``` * URLEncoded ```ts application/x-www-form-urlencoded ``` ```ts export enum DataType { JSON = 'application/json', GraphQL = 'application/graphql', URLEncoded = 'application/x-www-form-urlencoded', } ``` ### HeaderParams Headers to be sent with the request. ```ts Record ``` ### PostRequestParams ```ts GetRequestParams & { data: Record | string; } ``` ### GraphQLClient * query ```ts Operation extends keyof Operations ``` * options ```ts GraphQLQueryOptions ``` interface Promise\ { /\*\* \* 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\(onfulfilled?: ((value: T) => TResult1 | PromiseLike\) | undefined | null, onrejected?: ((reason: any) => TResult2 | PromiseLike\) | undefined | null): Promise\; /\*\* \* 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\(onrejected?: ((reason: any) => TResult | PromiseLike\) | undefined | null): Promise\; }, interface Promise\ {}, Promise: PromiseConstructor, interface Promise\ { readonly \[Symbol.toStringTag]: string; }, interface Promise\ { /\*\* \* 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\; } ```ts interface Promise { /** * 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(onfulfilled?: ((value: T) => TResult1 | PromiseLike) | undefined | null, onrejected?: ((reason: any) => TResult2 | PromiseLike) | undefined | null): Promise; /** * 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(onrejected?: ((reason: any) => TResult | PromiseLike) | undefined | null): Promise; }, interface Promise {}, Promise: PromiseConstructor, interface Promise { readonly [Symbol.toStringTag]: string; }, interface Promise { /** * 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; } ``` ```ts export type GraphQLClient = < Operation extends keyof Operations, >( query: Operation, options?: GraphQLQueryOptions, ) => Promise>; ``` ### GraphQLQueryOptions * variables The variables to pass to the operation. ```ts ApiClientRequestOptions["variables"] ``` * apiVersion The version of the API to use for the request. ```ts ApiVersion ``` * headers Additional headers to include in the request. ```ts Record ``` * tries The total number of times to try the request if it fails. ```ts number ``` ```ts export interface GraphQLQueryOptions< Operation extends keyof Operations, Operations extends AllOperations, > { /** * The variables to pass to the operation. */ variables?: ApiClientRequestOptions['variables']; /** * The version of the API to use for the request. */ apiVersion?: ApiVersion; /** * Additional headers to include in the request. */ headers?: Record; /** * The total number of times to try the request if it fails. */ tries?: number; } ``` ### ApiVersion * October22 ```ts 2022-10 ``` * January23 ```ts 2023-01 ``` * April23 ```ts 2023-04 ``` * July23 ```ts 2023-07 ``` * October23 ```ts 2023-10 ``` * January24 ```ts 2024-01 ``` * April24 ```ts 2024-04 ``` * Unstable ```ts unstable ``` ```ts 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', } ``` ### LegacyWebhookAdminApiContext * rest A REST client. ```ts RestClient & Resources ``` * graphql A GraphQL client. ```ts InstanceType ``` ```ts export interface LegacyWebhookAdminApiContext< Resources extends ShopifyRestResources, > { /** A REST client. */ rest: InstanceType & Resources; /** A GraphQL client. */ graphql: InstanceType; } ``` ### RestClient * loggedDeprecations ```ts Record ``` * client ```ts AdminRestApiClient ``` * session ```ts Session ``` * apiVersion ```ts ApiVersion ``` * get Performs a GET request on the given path. ```ts (params: GetRequestParams) => Promise> ``` * post Performs a POST request on the given path. ```ts (params: PostRequestParams) => Promise> ``` * put Performs a PUT request on the given path. ```ts (params: PostRequestParams) => Promise> ``` * delete Performs a DELETE request on the given path. ```ts (params: GetRequestParams) => Promise> ``` ```ts 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 = {}; 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(params: GetRequestParams) { return this.request({method: Method.Get, ...params}); } /** * Performs a POST request on the given path. */ public async post(params: PostRequestParams) { return this.request({method: Method.Post, ...params}); } /** * Performs a PUT request on the given path. */ public async put(params: PutRequestParams) { return this.request({method: Method.Put, ...params}); } /** * Performs a DELETE request on the given path. */ public async delete(params: DeleteRequestParams) { return this.request({method: Method.Delete, ...params}); } protected async request( params: RequestParams, ): Promise> { 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 = { 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 ```ts T ``` * headers ```ts Headers ``` * pageInfo ```ts PageInfo ``` ```ts export interface RestRequestReturn { body: T; headers: Headers; pageInfo?: PageInfo; } ``` ### Headers ```ts Record ``` ### PageInfo * limit ```ts string ``` * fields ```ts string[] ``` * previousPageUrl ```ts string ``` * nextPageUrl ```ts string ``` * prevPage ```ts PageInfoParams ``` * nextPage ```ts PageInfoParams ``` ```ts export interface PageInfo { limit: string; fields?: string[]; previousPageUrl?: string; nextPageUrl?: string; prevPage?: PageInfoParams; nextPage?: PageInfoParams; } ``` ### PageInfoParams * path ```ts string ``` * query ```ts SearchParams ``` ```ts export interface PageInfoParams { path: string; query: SearchParams; } ``` ### Examples * #### Update a metafield when a product is updated ##### Description Update a metafield when a product is updated ##### /app/routes/\*\*.ts ```typescript 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(); }; ``` ## Examples ### admin \[V3] Webhook admin context With the `v3_webhookAdminContext` future flag enabled, use the `admin` object in the context to interact with the Admin API. Webhook 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. ### 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 ```typescript 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 ```typescript 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({ 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 }); } ``` ### apiVersion Webhook API version Get the API version used for webhook request. ### Examples * #### Webhook API version ##### Description Get the API version used for webhook request. ##### /app/routes/webhooks.tsx ```typescript 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(); }; ``` ### shop Webhook shop Get the shop that triggered a webhook. ### Examples * #### Webhook shop ##### Description Get the shop that triggered a webhook. ##### /app/routes/webhooks.tsx ```typescript 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(); }; ``` ### topic Webhook topic Get the event topic for the webhook. ### Examples * #### Webhook topic ##### Description Get the event topic for the webhook. ##### /app/routes/webhooks.tsx ```typescript 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(); }; ``` ### webhookId Webhook ID Get the webhook ID. ### Examples * #### Webhook ID ##### Description Get the webhook ID. ##### /app/routes/webhooks.tsx ```typescript 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(); }; ``` ### payload Webhook payload Get the request's POST payload. ### Examples * #### Webhook payload ##### Description Get the request's POST payload. ##### /app/routes/webhooks.tsx ```typescript 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(); }; ``` ### subTopic Webhook sub-topic Get the webhook sub-topic. ### Examples * #### Webhook sub-topic ##### Description Get the webhook sub-topic. ##### /app/routes/webhooks.tsx ```typescript 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(); }; ``` ## Related [Interact with the Admin API. - Admin API context](https://shopify.dev/docs/api/shopify-app-remix/apis/admin-api)