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}` })34export 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
- Browser posts to
/apps/admin-api/servicewith{ service, operation, variables }. Same-origin, so no CORS and no key in the bundle. - Shopify's App Proxy forwards it to your deployment, adding an HMAC signature.
- 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.
- The runtime builds the request: resolves
baseUrl, callsheaders(env)andquery(env)for credentials, runspath(variables)andbody(variables), and refuses anything that isn'thttps://. - 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:
| Service | Auth | How |
|---|---|---|
klaviyo.js | Header | headers: (env) => ({ Authorization: … }) |
judgeme.js | Query string | query: (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 handled2const { data, loading } = useAPI(api.judgeme.listReviews, { productId })34// A write, on an event5const subscribe = useAPIAction(api.klaviyo.subscribe)6await subscribe.run({ email })78// Anywhere else — module scope, an event handler outside React9const 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.