React SDK

@wishsupport/react puts merchant support inside your Shopify app. It provides:

  • a support inbox page
  • a conversation page
  • a floating support widget

Each is built on Polaris web components, with a few custom-styled parts (the launcher, panel and message bubbles) you can theme. It uses the client API for you, with live updates over a WebSocket. Your API key stays on your server.

Before you start

  • Your app is a Shopify embedded app with App Bridge v4 and Polaris web components. The current Shopify app template includes both. In development the SDK logs a console warning if either is missing.
  • React 18 or later.
  • A server that can run a route handler built on the standard Request and Response: React Router, Remix, Next.js route handlers and similar.
  • A space owner has created an API key under Space → Settings → API keys.
  • The owner has set your App slug under Settings → Notifications, so merchant reply emails link back into your app.

1. Install

npm install @wishsupport/react
# server environment, never committed
WISHSUPPORT_API_KEY="<key from your space settings>"

2. Add the server route

The components call this route, and the route calls WishSupport. It adds your key and the shop to every call.

// app/routes/api.wishsupport.$.tsx
import { createSupportHandler } from '@wishsupport/react/server';
import { authenticate } from '../shopify.server';

const handler = createSupportHandler({
  apiKey: process.env.WISHSUPPORT_API_KEY!,
  identify: async (request) => {
    const { session } = await authenticate.admin(request);
    const shop = await getShopProfile(session); // your own lookup: name, contact email, plan
    return {
      shopDomain: session.shop,
      shopName: shop.name,
      requesterEmail: shop.contactEmail,
      plan: shop.planName,
    };
  },
});

export const loader = handler;
export const action = handler;

On Next.js, put the same handler in app/api/wishsupport/[...path]/route.ts and export it for each method:

import { createSupportHandler } from '@wishsupport/react/server';

const handler = createSupportHandler({
  apiKey: process.env.WISHSUPPORT_API_KEY!,
  identify: async (request) => {
    const shop = await getShopFromSession(request); // your own session check
    return { shopDomain: shop.domain, requesterEmail: shop.contactEmail };
  },
});

export { handler as GET, handler as POST, handler as PATCH, handler as DELETE };

identify runs for every request the route accepts and tells WishSupport which merchant is asking. Requests for an unknown path, or without the SDK’s X-WishSupport-Client header, are refused before it runs. It returns:

Field Required Description
shopDomain Yes The .myshopify.com domain from your authenticated session. Every read and write is limited to it.
requesterEmail Yes Where reply alerts for the merchant go, so it must be a real inbox.
shopName No Shown to your team. Defaults to the shop domain.
requesterName No The person writing in. Defaults to the shop name.
plan No The merchant’s plan in your app, recorded when a ticket is opened.

If identify returns null, throws, or returns an invalid domain or email, the route answers 401 and calls nothing. A thrown Response, such as the one authenticate.admin throws to make App Bridge fetch a fresh session token, goes back to the browser untouched. identify must not read the request body.

createSupportHandler options:

Option Default Description
apiKey Your space’s API key. Required.
identify See above. Required.
mountPath /api/wishsupport Where the route is mounted. It must equal the provider’s endpoint.
baseUrl https://api.wishsupport.app/v1 The WishSupport API.
fetch the runtime’s fetch The fetch the route uses to call WishSupport. Set it for tests or to route calls through a proxy.

3. Wrap your app

// app/routes/app.tsx
import { Outlet, useLocation, useNavigate } from 'react-router';
import { WishSupportProvider } from '@wishsupport/react';

export default function App() {
  const navigate = useNavigate();
  const { pathname } = useLocation();

  // keep your existing AppProvider and nav; WishSupportProvider only needs to wrap the pages
  return (
    <WishSupportProvider navigate={navigate} pathname={pathname}>
      <Outlet />
    </WishSupportProvider>
  );
}

This also adds the floating support widget. Pass launcher={{ show: false }} to hide the floating button. The widget panel still opens from useWishSupport().open().

On Next.js (App Router), the provider needs your router’s hooks, so wrap it in a client component and render that from your root layout:

// app/support-provider.tsx
'use client';

import { usePathname, useRouter } from 'next/navigation';
import type { ReactNode } from 'react';
import { WishSupportProvider } from '@wishsupport/react';

export function SupportProvider({ children }: { children: ReactNode }) {
  const router = useRouter();
  const pathname = usePathname();

  return (
    <WishSupportProvider navigate={router.push} pathname={pathname}>
      {children}
    </WishSupportProvider>
  );
}

Build theme and labels inside this wrapper: a server component cannot pass functions, such as ticketCreated, to a client component. DEFAULT_LABELS and SupportApiError are client exports, so use them only in 'use client' files.

WishSupportProvider props:

Prop Default Description
endpoint /api/wishsupport Where your server route is mounted.
basePath /app/support Where your support pages live. A ticket opens at <basePath>/<code>.
navigate full page load (path: string) => void. Pass your router’s navigate for in-app transitions.
launcher { show: true, position: 'bottom-right', offset: 0 } show: false hides the button only. position is 'bottom-right' or 'bottom-left'. offset adds pixels to the default 32px gap.
hideLauncherOn [basePath] Paths, and everything under them, where the whole widget is hidden.
pathname window.location.pathname Pass your router’s location so hideLauncherOn follows client-side navigation.
theme the admin’s own look primary, launcherGradient: [from, to], radius and fontSize, the last two in pixels. See Look and wording.
labels English Overrides for any text, including nested tabs, columns and status. See Look and wording.
live true Live updates over a WebSocket. false turns them off.

4. Add the support pages

// app/routes/app.support._index.tsx
import { SupportList } from '@wishsupport/react';

export default function Support() {
  return <SupportList />;
}
// app/routes/app.support.$ticketId.tsx
import { useParams } from 'react-router';
import { SupportTicket } from '@wishsupport/react';

export default function SupportConversation() {
  return <SupportTicket ticketId={useParams().ticketId!} />;
}

These pages live at /app/support and /app/support/<ticket code>, which is the provider’s default basePath. Pass basePath if you mount them elsewhere.

<SupportList /> props:

Prop Default Description
tabs ['all', 'active', 'closed', 'archived'] Which tabs to show, in order.
defaultTab 'active' The tab selected first. If it isn’t in tabs, the first entry of tabs is used.
columns ['index', 'date', 'code', 'status', 'lastMessage', 'lastReplied'] Which columns to show, in order.
pageSize 30 Tickets per page, from 1 to 100.
showSearch true false hides the search field.
withPage true false renders the list without its page, to place inside your own.
heading, breadcrumb labels.pageTitle, a “Home” link to /app Page header, used with withPage. breadcrumb={null} removes the link.
onOpenTicket navigates to the ticket page (code: string) => void.

<SupportTicket /> props:

Prop Default Description
ticketId Required. The ticket code, in any letter case.
withPage true false renders the conversation without its page.
onBack navigates to basePath Called by the breadcrumb and the not-found button.
agentName labels.agentName (Support Agent) Name shown above the conversation.

Open the widget from your own button

import { useWishSupport } from '@wishsupport/react';

function ContactSupport() {
  const { open, unreadCount } = useWishSupport();

  return <s-button onClick={open}>Contact support {unreadCount > 0 && `(${unreadCount})`}</s-button>;
}

useWishSupport() must be called under the provider. It returns:

Field Description
open, close Open or close the widget panel.
isOpen Whether the panel is open.
unreadCount Open and closed tickets with a reply the merchant hasn’t read.
liveStatus The live connection: 'live', 'connecting', 'reconnecting' or 'off'. It is 'off' with live={false}, after five minutes in a hidden tab, and while redials are paused. The widget’s dot is green only while 'live'.

It works with launcher={{ show: false }}: the button is hidden, the panel still opens. On the support pages and on hideLauncherOn paths the whole widget is hidden, so open() does nothing there.

Look and wording

import { WishSupportProvider, type DeepPartial, type Labels, type SupportTheme } from '@wishsupport/react';

// Module scope, so each object keeps one identity across renders.
const THEME: SupportTheme = {
  primary: '#5533ea',
  launcherGradient: ['#7a5cff', '#3d1fbf'],
  radius: 12,
  fontSize: 13,
};

const LABELS: DeepPartial<Labels> = {
  pageTitle: 'Help',
  tabs: { active: 'Open' },
  ticketCreated: (code) => `Request ${code} sent`,
};

<WishSupportProvider theme={THEME} labels={LABELS}>
  • theme colours the merchant’s message bubbles, the unread dot and the launcher. Polaris components keep the admin’s own look. Colours must be plain CSS colour values: one containing ;, quotes, url() or similar is ignored.
  • labels renames any text. Pass only the keys you change. Text with a value in it, like ticketCreated, is a function. DEFAULT_LABELS, exported from the package, lists every key.

How it behaves

  • A merchant’s reply to a closed or archived ticket reopens it.
  • Opening a conversation marks it read for the merchant.
  • Live updates arrive over a WebSocket, and the SDK catches up after a reconnect. It never polls on an interval.
  • Each message takes up to 4 files: up to 10 MB each, images up to 5 MB.
  • The route retries a rate-limited request (429, RATE_LIMIT_EXCEEDED) once. It retries a GET, PATCH or DELETE that fails with INTERNAL_ERROR once. It never retries a POST that fails with INTERNAL_ERROR, so a ticket or a message is not sent twice.
  • A failed send keeps the merchant’s draft in the composer, so they can send it again.

Good to know

  • Mount WishSupportProvider once, at the app root. It owns the live connection, the cache and the widget, and a second provider would add a second of each.
  • Hoist or memoise a labels object you pass. A new object on every render rebuilds the provider’s context and re-renders every SDK component.
  • The provider renders its stylesheet inline. To override the --ws-* variables, use the theme prop. Rules in your <head> lose to the inline sheet.
  • The widget is hidden on the support pages and on paths in hideLauncherOn. It renders on the client only.
  • The SDK keeps its own TanStack Query client and does not touch yours.
  • By default, live updates connect to wss://api.wishsupport.app. If your app sets a connect-src content security policy, allow that origin. With live={false}, data refreshes when the browser tab regains focus.
  • Every open tab holds a live connection against your key’s shared limits (10 requests a second and 500 connections), so the SDK closes a tab’s connection after five minutes hidden and catches up when it is shown again, spreads out redials after an outage, and pauses after five failed connection attempts until the tab is shown or the network returns.
  • The package ships unminified and without source maps.

Security

  • The API key is read only by @wishsupport/react/server. Never pass it to a component, and never expose it through a client-side env prefix such as NEXT_PUBLIC_ or VITE_.
  • The shop always comes from identify, which reads your authenticated Shopify session. The route ignores any shop or identity a browser sends, so one merchant cannot reach another’s tickets.
  • The route is not an open proxy. It forwards a fixed list of ticket, message, attachment and live-connection calls, with an allowlist of query parameters, and answers anything else with a 404.
  • It refuses requests without the SDK’s X-WishSupport-Client header and requires JSON request bodies (multipart for file uploads), which blocks cross-site form posts. An empty mark-read request is exempt from the JSON rule, as it has no body, but not from the header.
  • Don’t put a permissive CORS layer in front of the route, for example one that allows credentials together with custom headers. That would undo the cross-site protection.
  • Attachment URLs must be https. The route refuses a ticket or message that quotes any other URL; plain http is accepted only when baseUrl is itself http, for local development.

Troubleshooting

What you see Likely cause
404 with ROUTE_NOT_FOUND The provider’s endpoint and the handler’s mountPath differ.
404 from your framework, not WishSupport JSON The route file is not a splat, so the framework answers before the handler runs. Name it api.wishsupport.$.tsx, or put it at app/api/wishsupport/[...path]/route.ts. The SDK reports it as UNKNOWN_ERROR with status 404.
400 with VALIDATION_ERROR asking for the X-WishSupport-Client header Something other than the SDK is calling the route. The SDK sends the header on every request.
401 with UNAUTHORIZED identify returned null, threw, or returned an invalid domain or email. Read the response’s message: WishSupport itself also answers 401 for a missing, invalid or revoked API key.
500 with HANDLER_MISCONFIGURED WISHSUPPORT_API_KEY is not set on the server, or identify read the request body.
502 with UPSTREAM_UNREACHABLE Your server could not reach the WishSupport API. The route logs the reason to your server console. Check baseUrl and the server’s outbound network.
The widget does not appear You are on a support page or a hideLauncherOn path, or launcher.show is false. The widget renders on the client only.
Reply emails do not link back into your app The space owner has not set your App slug under Settings → Notifications.
New replies show up only when you refocus the tab or reload Either live={false}, which refreshes on tab focus by design, or the browser cannot reach the live socket. The socket origin follows the handler’s baseUrl, wss://api.wishsupport.app by default: allow it in your connect-src policy.

Prefer to build your own UI? Use the Client API quickstart instead.

Esc