Is There a Shopify Wishlist API?

Shopify has no Wishlist API. Use localStorage for quick guest wishlists or build a secure Metafield-based system via Admin API. Want both power and speed? Use the Froonze Wishlist extension.
June 11, 2025

TL;DR Shopify never shipped a dedicated Wishlist API.

You have two practical roads:

(A) keep things entirely in the browser with localStorage, or

(B) build a secure, server-side bridge that writes to Customer Metafields via the Admin API.

Why There’s No “Wishlist Endpoint” in Shopify API

Shopify exposes REST and GraphQL APIs for almost everything else, including products, carts, orders, and checkout.

Wishlists never made the cut, so merchants usually:

  1. Drop in an app.
  2. Roll their own – either:
    • Client-only (localStorage): zero backend, works for guests, gone if the browser cache is nuked.
    • Metafields + App Proxy: true account-level persistence, analytics-friendly, needs real engineering.

If you’re new, start with localStorage. When sales or feature demands outgrow that, you’d need to migrate the same UI to a metafield backend without the need for customer retraining.

Shopify Wishlist Build Checklist

Here’s what you need to do to get started with your own Shopify API:

Check Why it Matters
Enable Customer Accounts Needed later if you upgrade to Metafields.
Pick an identifier Product handle is Liquid-friendly; numeric ID is safer for APIs.
Decide guest vs. logged-in policy localStorage handles guests by default; accounts need a login prompt.
Never expose Admin API keys Your server (or an App Proxy) makes those calls—never the browser.

The 20-Minute localStorage Wishlist – Method 1

This method lets you create a temporary wishlist for your customers without needing to get into the messy backend code. It has its quirks, so decide for yourself:

✅ Pros ❌ Cons
Fast – zero backend latency Device-bound; desktop ≠ mobile
Works offline (cached data) Vanishes when user clears cache
No server costs No merchant analytics
Perfect MVP / proof-of-concept Impossible to add e-mails, price-drop alerts, etc.

That said, let’s dive in:

Step - 1: Add the Button

Inside your product template (or card snippet):

{% comment %}Show the button only for logged-in *or* guest users{% endcomment %}
<button class="wishlist-btn"
        data-product-handle="{{ product.handle }}"
        data-product-id="{{ product.id }}">
  ♥ Add to Wishlist
</button>

Style it however you like (heart icon, “Save for later,” etc.).

Step - 2: Connect Javascript

Create the file assets/wishlist.js and include it in your theme by adding a <script src="{{ 'wishlist.js' | asset_url }}"></script> tag just before the closing </body> tag in your theme.liquid file (typically found in the footer section):

document.addEventListener('DOMContentLoaded', () => {
  const STORAGE_KEY = 'shopifyWishlist';        // one array, JSON-encoded

  // read / write helpers
  const getList   = () => JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
  const setList   = (arr) => localStorage.setItem(STORAGE_KEY, JSON.stringify(arr));
  const toggleBtn = (btn, inList) => {
    btn.classList.toggle('is-added', inList);
    btn.textContent = inList ? '✓ Wishlisted' : '♥ Add to Wishlist';
  };

  // initialise every button on page load
  document.querySelectorAll('.wishlist-btn').forEach(btn => {
    const pid = btn.dataset.productId;              // string
    toggleBtn(btn, getList().includes(pid));

    btn.addEventListener('click', () => {
      let list = getList();
      const idx = list.indexOf(pid);

      if (idx >= 0) { list.splice(idx, 1); }        // remove
      else         { list.push(pid); }              // add

      setList(list);
      toggleBtn(btn, idx < 0);
    });
  });
});

Here’s what this script is doing:

  • Reads/writes one JSON array in localStorage.
  • Toggles button state instantly; no reload, no server call.
  • Safe for guests – their list lives in the browser until cleared.

Step - 3  Build the Wishlist Page / Component

To get started, you need to create a new template – templates/page.wishlist.liquid.

Next, markup the container; this is one way to do it:

<div class="wishlist-page">
  <h1>{{ page.title }}</h1>
  <div id="wishlist-grid"></div>
</div>

<script src="{{ 'wishlist.js' | asset_url }}"></script>
<script>
  document.addEventListener('DOMContentLoaded', renderWishlist);

  async function renderWishlist() {
    const grid   = document.getElementById('wishlist-grid');
    const ids    = JSON.parse(localStorage.getItem('shopifyWishlist') || '[]');

    if (ids.length === 0) {
      grid.innerHTML = '<p>Your wishlist is empty.</p>';
      return;
    }

    // Fetch each product’s JSON (Shopify AJAX API)
    const cards = await Promise.all(ids.map(async id => {
      try {
        const res  = await fetch(`/products/${id}.js`);   // works if id is *handle*
        const p    = await res.json();
        return `
          <div class="wishlist-item">
            <a href="${p.url}">
              <img src="${p.featured_image}" alt="${p.title}">
              <h3>${p.title}</h3>
            </a>
            <p>${Shopify.formatMoney(p.price, theme.moneyFormat)}</p>
          </div>`;
      } catch(e) { console.error('Missing product', id); return ''; }
    }));

    grid.innerHTML = cards.join('');
  }
</script>

Note: If you stored numeric IDs instead of handles, map them to handles first (AJAX /products/<handle>.js only accepts handles). Small stores often pick handles because they’re easier to read and work straight out of Liquid later.

Important: Read Before Touching Metafields

LocalStorage doesn’t hit your backend, so there’s no credential risk, but as soon as you step up to Metafields you must:

  1. Hide Admin tokens on a server or App Proxy. Never inline them.
  2. Validate the Shopify HMAC on every proxy request.
  3. Rate-limit writes; Admin API caps calls per second.
  4. Double-check ownership – customer A can’t edit customer B’s list.
  5. Serve all writes with POST/PUT only; block GET for mutating actions.

If you want everything (multi-list support, cross-device sync, price-drop emails, guest → account sync, Klaviyo flows, native account-page widget) install Froonze VIP Loyalty & Wishlist and be done by lunch.

You still keep control (custom CSS, JS hooks, optional), but the heavy lifting, UI polish, and compliance land on our plate, not yours.

Account-level Build Using Customer Metafields + App Proxy – Method 2

In method 1, we parked everything in localStorage. Great MVP, terrible for cross-device shoppers. 

Now we wire the same UI to Shopify’s Admin API, safely, so wishlists live in the customer record and follow them everywhere:

Basic Blueprint

Browser   ──▶  /apps/wishlist/add     (App Proxy URL on your store)
          Your App/Function (Node, Ruby, etc.)
                 │  (Admin API – secured token, server-side only)
     Customer Metafield  namespace:"wishlist"  key:"products"
          (same path for /remove, /list, etc.)

You’re getting the following out of this Shopify API method:

  • App Proxy keeps CORS simple and injects
    logged_in_customer_id so you never trust a raw ID from JS.
  • Customer Metafield stores an array of product GIDs (JSON).
  • Storefront API (public token) fetches product cards for display.

Step - 1: Set Up the Metafield

  1. Admin → Settings → Custom data → Customers → “Add definition”
    Namespace: wishlist Key: products Type: JSON
    Default value: [].

  2. Save. 

Every customer now has customer.metafields.wishlist.products.

Step - 2: Create a Custom App

  1. Apps → Develop apps → “Create an app”
  2. API scopes:
    • Customers – read & write
    • Products – read (if you’ll query via Admin)
  3. Install → copy the Admin access token → .env, never the theme.

Step - 3: Hello Proxy (Node + Express example)

From here onwards, it’s your own creative playground. But let’s try setting it up with the above constraints:

In the app admin → App proxy

 Subpath: /apps/wishlist
 Sub-path prefix: https://your-server.com/proxy

Now Shopify will forward

GET /apps/wishlist/ping → https://your-server.com/proxy/ping

 and append HMAC + logged_in_customer_id.

Harden the Gate

import crypto from 'crypto';
import express from 'express';
import {Shopify} from '@shopify/shopify-api';

const app   = express();
const token = process.env.SHOPIFY_ADMIN_TOKEN;
const shop  = process.env.SHOP;                     // my-store.myshopify.com
const API   = new Shopify.Clients.Rest(shop, token);

// — validate every proxy hit —
function verifyProxy(req, res, next) {
  const {shop, path_prefix, timestamp, signature} = req.query;
  const base = Object
      .entries({shop, path_prefix, timestamp})
      .map(([k,v]) => `${k}=${v}`)
      .sort()
      .join('');

  const calc = crypto
      .createHmac('sha256', process.env.SHOPIFY_API_SECRET)
      .update(base)
      .digest('hex');

  if (calc !== signature) return res.status(401).send('Bad HMAC');
  next();
}

app.use(express.json());
app.use('/proxy', verifyProxy);

Add /add and /remove routes

app.post('/proxy/add', async (req, res) => {
  const customerId = req.query.logged_in_customer_id;
  const {productGid} = req.body;
  if (!customerId) return res.status(401).send('Login required');

  // 1. read current metafield
  const meta = await API.get({
    path: `customers/${customerId}/metafields`,
    query: { namespace: 'wishlist', key: 'products' }
  });

  let list = [];
  if (meta.body.metafields.length) {
    list = JSON.parse(meta.body.metafields[0].value);
  }

  if (!list.includes(productGid)) list.push(productGid);

  // 2. upsert metafield
  const payload = {
    namespace: 'wishlist',
    key: 'products',
    type: 'json',
    value: JSON.stringify(list),
  };

  if (meta.body.metafields.length) {
    await API.put({path: `metafields/${meta.body.metafields[0].id}`, data: {metafield: payload}, type:'json'});
  } else {
    payload.owner_id   = customerId;
    payload.owner_resource = 'customer';
    await API.post({path:'metafields', data:{metafield: payload}, type:'json'});
  }

  res.json({ok:true, list});
});

app.post('/proxy/remove', async (req, res) => {
  const customerId = req.query.logged_in_customer_id;
  const {productGid} = req.body;
  if (!customerId) return res.status(401).send('Login required');

  const meta = await API.get({
    path: `customers/${customerId}/metafields`,
    query: { namespace: 'wishlist', key: 'products' }
  });

  if (!meta.body.metafields.length) return res.json({ok:true, list:[]});

  let list = JSON.parse(meta.body.metafields[0].value)
                .filter(gid => gid !== productGid);

  await API.put({
    path: `metafields/${meta.body.metafields[0].id}`,
    data: { metafield: { value: JSON.stringify(list) } },
    type: 'json'
  });

  res.json({ok:true, list});
});

No Admin key leaves the server.

HMAC confirms every call is Shopify-signed. logged_in_customer_id guarantees users can touch only their own list.

Deploy (on Render, Fly.io, etc.). Save the public URL in the proxy settings.

Step - 4: Front-End: swap storage layer, keep UX

Replace the localStorage calls in method 1 JS:

async function wishlist(action, productGid) {
  const res = await fetch(`/apps/wishlist/${action}`, {
    method : 'POST',
    headers: {'Content-Type':'application/json'},
    body   : JSON.stringify({productGid})
  });
  return res.json();     // {ok:true, list:[…]}
}

Use product GIDs:

data-gid="{{ product.admin_graphql_api_id }}"

Button click becomes:

await wishlist('add', gid);
toggleBtn(btn,true);

Same for remove.

Step - 5: Rendering the Wishlist Page (Storefront API)

Metafield now stores GIDs, so fetch data in bulk:

const query = `
  query ($ids:[ID!]!) {
    nodes(ids:$ids) {
      ... on Product {
        id title handle onlineStoreUrl
        featuredImage { url }
        priceRange { minVariantPrice { amount currencyCode } }
      }
    }
  }`;

async function render() {
  const res1 = await fetch('/apps/wishlist/list');          // optional “get” endpoint
  const {list} = await res1.json();                         // array of GIDs

  const res2 = await fetch('/api/2024-04/graphql.json', {
    method:'POST',
    headers:{
      'Content-Type':'application/json',
      'X-Shopify-Storefront-Access-Token': STOREFRONT_TOKEN
    },
    body: JSON.stringify({query, variables:{ids:list}})
  });

  const {data:{nodes}} = await res2.json();
  // build HTML grid from nodes, use creative liberty…
}

Optional Step: Guest → Account Sync

Keep the method 1 localStorage list for guests.

On login/signup (window.customerData becomes available or check Shopify.customerId) run:

const local = JSON.parse(localStorage.getItem('shopifyWishlist') || '[]');

if (local.length) {
  await fetch('/apps/wishlist/merge', {method:'POST', body:JSON.stringify({local})});
  localStorage.removeItem('shopifyWishlist');
}

Back-end merges and de-dupes. Shoppers keep what they saved before login.

How to Skip Custom Code?

If you like the idea of a Metafield-powered wishlist but not the upkeep, try Froonze.

It fires Javascript events every time the wishlist app loads or mutates. You can hook into those events to run analytics, marketing automations, or custom UI updates without having to touch Shopify’s Admin API. So, essentially:

  • No extra rate limits — you’re piggy-backing on Froonze’s own proxy calls, so your store’s Admin API budget stays untouched.
  • Account-aware out-of-the-box — events already contain the logged-in customer’s wishlist; guest mode still works courtesy of Froonze cookies. 
  • Composable — funnel the payload into Shopify Flow, GA4, or Klaviyo without rebuilding Metafield logic.

Bottom line: install Froonze VIP Loyalty & Wishlist, listen to three events, and you’ve got an instant, code-light Wishlist API that plays nicely with the rest of your stack.

Recap

  • localStorage = quickest launch, zero backend, stays on one device.
  • Metafields + Proxy = true account persistence, analytics-ready, but demands server code, HMAC checks, and rate-limit savvy.
  • Froonze = all the power, none of the maintenance.

Pick the ladder rung that matches today’s resources, and know you can always climb higher later. Good luck!

Froonze Customer Accounts Concierge
Get the app today
Turn the account page into a beautiful portal with
Froonze Customer Accounts Concierge
  • 250+