Build a Klaviyo Back-in-Stock Waitlist for Shopify Customer Accounts Without Exposing Your API Key
A client wanted customers to see the products they’re waiting on when they open their account. The data was already in Klaviyo: every back-in-stock signup from the product page. It just wasn’t visible anywhere on the storefront.
This walkthrough covers the whole build: the Klaviyo API calls, a theme section, the script that turns events into product cards, and the server-side piece the browser can’t do. Code is condensed from the production version.
What you need
- A Klaviyo private API key with
profiles:readandevents:read. It never goes in the theme. - The ID of the Subscribed to Back in Stock metric.
- A Storefront API access token. It’s public by design, so it can live in a theme setting.
- Somewhere server-side to call Klaviyo from. More on that at the end.
To find the metric ID, list your metrics and look for the one named Subscribed to Back in Stock:
curl -g 'https://a.klaviyo.com/api/metrics/' \
-H 'Authorization: Klaviyo-API-Key pk_your_private_key' \
-H 'revision: YYYY-MM-DD' \
-H 'accept: application/vnd.api+json'
Use a current API revision date from Klaviyo’s docs for revision.
How Klaviyo stores a waitlist
There is no waitlist object. Each signup is a Subscribed to Back in Stock event on the shopper’s profile, and the Events API filters by profile ID, not email. So getting one customer’s waitlist takes two calls:
# 1. Email -> profile ID
curl -g 'https://a.klaviyo.com/api/profiles/?filter=equals(email,"jane@example.com")&fields[profile]=id' \
-H 'Authorization: Klaviyo-API-Key pk_your_private_key' \
-H 'revision: YYYY-MM-DD' \
-H 'accept: application/vnd.api+json'
# 2. Profile ID + metric ID -> back-in-stock events
curl -g 'https://a.klaviyo.com/api/events/?filter=and(equals(profile_id,"01J..."),equals(metric_id,"AbC123"))&sort=-datetime&page[size]=200' \
-H 'Authorization: Klaviyo-API-Key pk_your_private_key' \
-H 'revision: YYYY-MM-DD' \
-H 'accept: application/vnd.api+json'
The product is in each event’s attributes.event_properties. Events from Klaviyo’s Shopify back-in-stock form carry ProductID and VariantId, with a lowercase d. They don’t reliably include a product handle, so you can’t build a product URL from the event alone.
A shopper who signs up for two sizes of the same product creates two events, and signing up twice for the same size creates duplicates. Deduplicate by variant ID.
The theme section
The section only renders the waitlist for logged-in customers. Everything the script needs comes in as data attributes: the proxy route, the Storefront API token and the API version. No email, profile ID or metric ID is passed to the browser.
{% if customer %}
<waitlist-page
data-endpoint="/apps/proxy/klaviyo-events"
data-storefront-token="{{ settings.storefront_api_token }}"
data-api-version="2025-10"
>
<p data-waitlist-loading>Loading your waitlist...</p>
<p data-waitlist-empty hidden>You're not waiting on any items.</p>
<p data-waitlist-error hidden>We couldn't load your waitlist.</p>
<ul class="product-grid" data-waitlist-grid role="list" hidden></ul>
</waitlist-page>
{% else %}
<a href="{{ routes.account_login_url }}">Log in to see your waitlist</a>
{% endif %}
storefront_api_token is a plain text setting added to config/settings_schema.json.
The script
Three steps: fetch the events, resolve every variant in one Storefront API call, then fetch a rendered card for each variant from the theme itself.
// assets/waitlist-page.js
class WaitlistPage extends HTMLElement {
async connectedCallback() {
this.grid = this.querySelector('[data-waitlist-grid]');
try {
const res = await fetch(this.dataset.endpoint, { headers: { Accept: 'application/json' } });
if (!res.ok) throw new Error(`waitlist ${res.status}`);
const { data = [] } = await res.json();
const items = await this.resolveVariants(this.itemsFromEvents(data));
const cards = (await Promise.all(items.map((item) => this.fetchCard(item)))).filter(Boolean);
if (!cards.length) return this.setState('empty');
this.grid.replaceChildren(...cards);
this.setState('grid');
} catch (err) {
console.error('[waitlist]', err);
this.setState('error');
}
}
setState(state) {
for (const name of ['loading', 'empty', 'error', 'grid']) {
const el = this.querySelector(`[data-waitlist-${name}]`);
if (el) el.hidden = name !== state;
}
}
// One item per variant. Klaviyo's Shopify events use "VariantId".
itemsFromEvents(events) {
const seen = new Set();
const items = [];
for (const event of events) {
const props = event.attributes?.event_properties || {};
const variantId = String(props.VariantId || props.VariantID || props.variant_id || '');
if (!variantId || seen.has(variantId)) continue;
seen.add(variantId);
items.push({ variantId });
}
return items;
}
// Variant IDs -> product handles in one call. Variants of unpublished or
// deleted products, and removed variants, come back null and are dropped.
async resolveVariants(items) {
if (!items.length) return [];
const res = await fetch(`/api/${this.dataset.apiVersion}/graphql.json`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Shopify-Storefront-Access-Token': this.dataset.storefrontToken,
},
body: JSON.stringify({
query: `query ($ids: [ID!]!) {
nodes(ids: $ids) { ... on ProductVariant { id product { handle } } }
}`,
variables: { ids: items.map((i) => `gid://shopify/ProductVariant/${i.variantId}`) },
}),
});
const { data } = await res.json();
return (data?.nodes || [])
.filter((node) => node?.product)
.map((node) => ({ variantId: node.id.split('/').pop(), handle: node.product.handle }));
}
// The card markup comes from an alternate product template.
async fetchCard({ handle, variantId }) {
const root = window.Shopify?.routes?.root || '/';
const url = `${root}products/${encodeURIComponent(handle)}?view=waitlist-card&variant=${variantId}`;
const res = await fetch(url);
if (!res.ok) return null;
const doc = new DOMParser().parseFromString(await res.text(), 'text/html');
return doc.querySelector('[data-waitlist-item]');
}
}
if (!customElements.get('waitlist-page')) customElements.define('waitlist-page', WaitlistPage);
The nodes query also does the validation. Without it, a waitlist for a product that was later unpublished renders a broken card or a 404.
Rendering cards with an alternate template
Instead of building cards in JavaScript, the script asks Shopify to render them. ?view=waitlist-card loads templates/product.waitlist-card.liquid, and ?variant= selects the variant the customer is waiting on, so prices, images and availability are all correct for that size or colour.
{%- layout none -%}
{%- assign v = product.selected_or_first_available_variant -%}
<li data-waitlist-item>
<a href="{{ product.url }}?variant={{ v.id }}">
{{ v.featured_image | default: product.featured_image | image_url: width: 600 | image_tag: loading: 'lazy' }}
<span>{{ product.title }}, {{ v.title }}</span>
</a>
{% render 'price', product: product, use_variant: true %}
{%- form 'product', product -%}
<input type="hidden" name="id" value="{{ v.id }}">
<button type="submit"{% unless v.available %} disabled{% endunless %}>
{%- if v.available -%}Add to cart{%- else -%}Sold out{%- endif -%}
</button>
{%- endform -%}
</li>
In production this template reuses the theme’s own card classes and colour swatch logic, so waitlist cards match the collection grid. layout none keeps the response to just the card.
Two details that cost time:
- If you wrap the form in the theme’s
<product-form>for AJAX add to cart, make sureproduct-form.jsis loaded on the account page. A<script>tag inside markup inserted with JavaScript never runs, so load it from the script or fromtheme.liquid. - If your account pages come from a customer account app that injects theme markup into its own tab, the same rule applies to
waitlist-page.jsitself. Load it globally. The custom element upgrades whenever the markup appears.
Why you can’t call the Klaviyo API from Liquid or theme JavaScript
The obvious first attempt is calling Klaviyo straight from the theme. It fails for three separate reasons.
Liquid can’t make HTTP requests. Liquid renders on Shopify’s servers before the page is sent, and it has no way to call an external API. Any call to a third-party API from a Shopify theme has to happen in the browser or on a server.
The API key would be exposed. Both calls need the private key, and that key reads every profile and event in the account. An API key in theme JavaScript, a snippet, a theme setting or a metafield ends up in the page source, where anyone can read it in dev tools. There is no way to hide an API key inside a Shopify theme. Klaviyo’s public key only covers client endpoints like subscribing a profile, not reading them.
CORS. Even with the key, the browser blocks the call. Klaviyo’s /api/ endpoints are built for servers, so a fetch from the theme ends in this:
Access to fetch at 'https://a.klaviyo.com/api/profiles/' from origin
'https://your-store.com' has been blocked by CORS policy: No
'Access-Control-Allow-Origin' header is present on the requested resource.
Or “Response to preflight request doesn’t pass access control check.” It’s one of the most common Shopify Community questions, usually phrased as “how do I bypass the CORS policy when calling an API from my store?” mode: 'no-cors' doesn’t help, because the response comes back opaque and unreadable. Public CORS proxies work, but your key passes through someone else’s server.
None of this is specific to Klaviyo. The same applies whenever you call a third-party API from a Shopify storefront: an ERP, a loyalty program, a shipping or reviews API, or OpenAI.
The fix for all three is a Shopify app proxy. The theme calls /apps/proxy/klaviyo-events on the store’s own domain, so it’s a same-origin request and CORS never applies. Shopify forwards it to a server, signed, and the server calls Klaviyo with the key.
The rule that matters: the server picks the customer
Our first version got this wrong. The theme built the Klaviyo filter from customer.email and sent it through the proxy. The key was hidden, but the proxy forwarded whatever filter it received, so anyone could request someone else’s waitlist by changing the email, or drop the filter and list profiles.
Shopify gives you a trustworthy identity for free. On every app proxy request from a logged-in customer it adds logged_in_customer_id and signs the request. Build everything from that and accept nothing else from the browser. If you write the proxy yourself:
- Verify the
signatureparameter on every request: HMAC-SHA256 of the sorted query parameters with your app’s client secret. - Return 403 when
logged_in_customer_idis empty. Shopify sends it empty for guests. - Look up the customer’s email through the Admin API from that ID. This needs the
read_customersscope and Shopify’s protected customer data approval for email. - Call Klaviyo Get Profiles with that email and
fields[profile]=id, so names, phone numbers and addresses are never fetched. - Call Get Events with a filter you build on the server. Ignore every query parameter the browser sends.
- Return the events JSON. A customer with no Klaviyo profile gets an empty list, not an error.
The same route in SecureBite
We built SecureBite API Proxy so this doesn’t need a custom backend. It runs the app proxy on Cloudflare’s edge, and the whole server side above is one route:
| Route name | klaviyo-events |
| Method | GET |
| Target URL | https://a.klaviyo.com/api/events/ |
| Secret headers | Authorization: Klaviyo-API-Key pk_...revision: YYYY-MM-DDaccept: application/vnd.api+json |
| Require logged-in customer | On |
| Forward only mapped params | On |
| Secret query params | filter = and(equals(profile_id,"{{klaviyo_profile_id}}"),equals(metric_id,"AbC123"))sort = -datetimepage[size] = 200 |
{{klaviyo_profile_id}} is a placeholder you can use in a secret query param on any route. SecureBite fills it in on the server: it gets the logged-in customer’s email from Shopify, looks up their Klaviyo profile ID with the route’s own headers, and then makes the call. The browser never sends or sees the email or the profile ID. With Forward only mapped params on, any filter or ID the browser adds is dropped.
The placeholder only works on routes whose Target URL is on a.klaviyo.com and that have Require logged-in customer turned on, so the key is only ever sent to Klaviyo. Saving any other route with it shows an error.
If the customer has no Klaviyo profile, the route returns {"data": []} and skips the Get Events call. When the lookup runs, the request counts as 2 toward your monthly limit.
Two more things protect routes like this one. Routes that require login have a per-customer rate limit of 30 requests a minute, so one account can’t hammer the route. And when you tick Require logged-in customer on a new route, Forward only mapped params turns on automatically, with a warning if you switch it off.
The route tester in the SecureBite admin can’t run routes that use placeholders, because there is no logged-in customer there. Test them from the storefront while logged in as a customer.
The theme then calls the route with no parameters, exactly like the script above: fetch('/apps/proxy/klaviyo-events').
Common questions
How do I hide an API key in a Shopify theme?
You can’t hide it inside the theme. Everything in theme code, including theme settings and metafields, reaches the browser. Move the API call to a server behind a Shopify app proxy, keep the key there, and have the theme call the proxy path on your own domain.
How do I fix “blocked by CORS policy” when calling an API from Shopify?
Don’t call the API from the browser. Call a Shopify app proxy path on your store, like /apps/proxy/your-route, and let the server behind it call the API. The browser only talks to your own domain, so there is no cross-origin request and no CORS error.
Can Liquid call an external API?
No. Liquid has no HTTP client. Either fetch the data from JavaScript through an app proxy, or sync it into metafields or metaobjects on a schedule so Liquid can read it directly.
Is it safe to put the Storefront API token in the theme?
Yes. The Storefront API access token is public by design and only has the unauthenticated scopes you grant it. Admin API tokens and third-party private keys are different: they must stay on a server.
How do I show customer-specific API data on a Shopify account page?
Use an app proxy and identify the customer on the server from logged_in_customer_id, which Shopify adds and signs on every proxy request from a logged-in customer. Never take an email or customer ID from the browser to decide whose data to return.
Call any API from your Shopify theme without exposing your keys or running into CORS.
- API keys stored in the app and added on the server
- CORS-blocked APIs become reachable from your theme
- Customer-only routes, filtered by Shopify’s signed login
- Runs on Cloudflare’s edge network, nothing to host
- Free plan available
Building something similar on Klaviyo or another API? We’re happy to talk shop.