The API

Third-party services

One endpoint for every other API.

A file in src/api/services/ defines where requests go and what credentials they carry. Drop it in, and npm run api:build registers it.

1export const baseUrl = 'https://a.klaviyo.com/api'
2export const headers = (env) => ({ Authorization: `Klaviyo-API-Key ${env.KLAVIYO_API_KEY}` })
3
4export const operations = {
5 subscribe: {
6 method: 'POST',
7 path: '/profile-subscription-bulk-create-jobs',
8 // Named explicitly — a caller cannot add fields this was not meant to expose.
9 body: ({ email }) => ({ data: { attributes: { email } } }),
10 },
11}
const result = await service('klaviyo.subscribe', { email })

Credentials come from the deployment's environment and never touch the theme.

One call, end to end

  1. Browser posts to /apps/admin-api/service with { service, operation, variables }. Same-origin, so no CORS and no key in the bundle.
  2. Shopify's App Proxy forwards it to your deployment, adding an HMAC signature.
  3. The endpoint verifies that signature, then looks up the service and operation. Unknown either way is a 404 that lists what does exist — names only, never URLs or headers.
  4. The runtime builds the request: resolves baseUrl, calls headers(env) and query(env) for credentials, runs path(variables) and body(variables), and refuses anything that isn't https://.
  5. The upstream responds. A 4xx is relayed with its details; a 5xx becomes a bare 502, because the body may leak internals.

Body builders name fields explicitly

body: ({ name, email, rating }) => ({ platform: 'shopify', name, email, rating })

Spreading the caller's variables would let a form on your storefront set platform — or any other field the upstream happens to accept — just by adding a hidden input.

Two auth styles

APIs split roughly evenly, so both ship as working examples:

ServiceAuthHow
klaviyo.jsHeaderheaders: (env) => ({ Authorization: … })
judgeme.jsQuery stringquery: (env) => ({ api_token: … })

Service-level query is applied last, so an operation can't shadow the credentials. Operations add their own query(variables) for filters and paging; values are URL-encoded, and undefined is dropped rather than sent as the string "undefined".

What the build enforces

npm run api:build fails if a service is missing baseUrl or operations, or if baseUrl isn't https:// — so a typo is a build error rather than a 401 in production. A service with neither headers nor query warns, since a genuinely public API needs neither.

Calling it

1// In a component — loading, error and abort handled
2const { data, loading } = useAPI(api.judgeme.listReviews, { productId })
3
4// A write, on an event
5const subscribe = useAPIAction(api.klaviyo.subscribe)
6await subscribe.run({ email })
7
8// Anywhere else — module scope, an event handler outside React
9const result = await service('klaviyo.subscribe', { email })

Adding a service puts its operations on the generated registry, so api.klaviyo.subscribe autocompletes as soon as the next build runs.

A note on Judge.me

It keys reviews by its own internal product id, not the Shopify one, so fetching reviews is two calls:

1const { product } = await service('judgeme.findProduct', { productId: shopifyProductId })
2const { reviews } = await service('judgeme.listReviews', { productId: product.id })

That first id never changes, so it's worth caching. Passing a Shopify id straight to listReviews returns an empty list rather than an error.