Skip to content

Add an AI shopping assistant to a headless store

If you don’t run Shopify or WooCommerce - a headless storefront, a bespoke stack, or a platform we don’t natively connect - you can still run Zubby. Instead of a sync connector, you push your catalog and orders to Zubby over the public REST API. They land in the same store-scoped catalog and order records used by the agent, so product search, recommendations, and eligible order-aware tools can use the data you send. Custom checkout links and data freshness differ from the managed connectors, as explained below.

What you own vs. what we own

You own the authoritative product, inventory, and order state and push it to us. We own retrieval, the AI agent, recovery, analytics, and the widget. There is no live callback to your store - inventory and order status are as fresh as your last push, so push on every change.

1. Create a custom store

From onboarding choose Custom / API (the third option beside Shopify and WooCommerce). Enter your storefront URL - it’s used for links and the widget origin allow-list. Unlike the platform connectors there’s no OAuth or plugin handshake, so the workspace activates immediately and you land on a setup guide with your store ID.

2. Mint a write API key

Open Settings → API Keys and create a key with the write scope (catalog + order ingest need write; the read endpoints below need read). Public API keys require the Pro plan or higher. The secret is shown once - store it in a secret manager, never commit it or expose it in browser code.

Every request authenticates with a Bearer token:

bash
Authorization: Bearer sp_live_<prefix>.<secret>

The store is derived from the key - there is no separate store-ID header on the public API.

3. Push your catalog

POST products (each with its variants) to /api/public/v1/catalog. The call is idempotent on your own externalId - re-send the same product to update it in place. Up to 200 products per request. Products become AI-searchable after indexing finishes; verify representative searches before sending live traffic.

bash
curl -X POST https://app.zubbyai.com/api/public/v1/catalog \
  -H "Authorization: Bearer sp_live_<prefix>.<secret>" \
  -H "Content-Type: application/json" \
  -d '{
    "products": [
      {
        "externalId": "SKU-1",
        "title": "Waxed Field Jacket",
        "description": "Waxed cotton, charcoal, brass hardware.",
        "vendor": "Northbound",
        "productType": "Outerwear",
        "status": "active",
        "url": "https://shop.yourbrand.com/p/field-jacket",
        "imageUrl": "https://cdn.yourbrand.com/jacket.jpg",
        "tags": ["jackets", "waxed-cotton"],
        "variants": [
          {
            "externalId": "SKU-1-M",
            "title": "Medium",
            "sku": "FJ-CHAR-M",
            "price": 189.00,
            "compareAtPrice": 220.00,
            "inventory": 12,
            "requiresShipping": true,
            "imageUrl": "https://cdn.yourbrand.com/jacket-m.jpg"
          }
        ]
      }
    ]
  }'

Response:

json
{ "upserted": 1, "received": 1, "errors": [] }

Only externalId and title are required on a product, and only externalId and title on a variant (price defaults to 0 when omitted - send it). A product with no variants is allowed but won’t be purchasable through checkout tools. Money fields accept a number or a string.

4. Push orders (optional)

POST orders to /api/public/v1/orders so the agent’s order status and reorder tools can use current order data when shopper identity, permissions, and store configuration allow. Writes are idempotent on externalId; re-push to update financial/fulfillment status (line items are replaced on each push). The original orderedAt is preserved across re-syncs.

bash
curl -X POST https://app.zubbyai.com/api/public/v1/orders \
  -H "Authorization: Bearer sp_live_<prefix>.<secret>" \
  -H "Content-Type: application/json" \
  -d '{
    "orders": [
      {
        "externalId": "1001",
        "email": "shopper@example.com",
        "currency": "USD",
        "subtotal": 189.00,
        "total": 199.00,
        "financialStatus": "paid",
        "fulfillmentStatus": "unfulfilled",
        "shippingCountry": "US",
        "shippingZip": "97201",
        "orderedAt": "2026-06-16T12:00:00Z",
        "shopperSessionId": "3b0f2c3e-8a41-4d6e-9f0a-2c7d5e1b9a64",
        "items": [
          {
            "title": "Waxed Field Jacket",
            "platformProductId": "SKU-1",
            "platformVariantId": "SKU-1-M",
            "quantity": 1,
            "unitPrice": 189.00,
            "lineTotal": 189.00
          }
        ]
      }
    ]
  }'

Match order line items to the catalog

Set each item’s platformVariantId to the variant externalId you pushed in the catalog. Reorder uses it to rebuild the cart and check current stock - when it can’t resolve a variant it links the item’s product page instead.

Pushed orders go through the same pipeline as Shopify and WooCommerce orders: AI attribution (revenue and profit, from each line’s cost or the catalog cost), post-purchase journeys, the AI-sale notification, the shopper’s order history and loyalty points. Each first-time effect happens once per order, so re-pushing is always safe.

  • Send orderedAt for live orders. Orders placed more than 48 hours ago are recorded in full but send no journeys, notifications or ad-platform events, so importing your history never emails past buyers. An order first sent without orderedAt is treated the same way, because we can’t tell when it was placed.
  • Attribution evidence. An order is credited to the AI when the buyer was shown one of its products by the assistant in the 30 minutes before checkout. Matching uses the checkout email, plus two optional fields that make it exact: shopperSessionId (the widget session, see below) and cartToken (your cart id, if you pass it to the widget).

5. Embed the widget

Drop the zubby-widget web component on every storefront page. Both the store ID and the widget key are auto-provisioned when the store is created and shown on your setup guide (the widget key is narrow-scope and rate-limited, so it’s safe to ship to the browser - unlike the write key above).

html
<script src="https://app.zubbyai.com/widget-element.js" defer></script>
<zubby-widget
  store-id="YOUR_STORE_ID"
  widget-key="YOUR_WIDGET_KEY"
  app-base="https://app.zubbyai.com"
></zubby-widget>

See widget embed codes for framework-specific variants (React, Vue, Next.js) and SPA boot notes. Everything below works with both the web component and the dashboard’s loader <script> snippet.

6. Sign in your customers (optional)

The widget only trusts a shopper’s identity when your server vouches for it. A signed identity lets the assistant greet the customer, look up their orders without asking for an order number, and show and redeem their loyalty points. Anything the browser merely claims is used for a greeting at most.

  1. Open Settings → API Keys → Storefront identity and generate a signing secret (store owners and admins only). It is shown once: keep it server-side next to your API key.
  2. For a signed-in customer, compute idToken = "v1." + exp + "." + hex(HMAC_SHA256(secret, storeId|customerId|email|exp)) on your server, where email is trimmed and lowercased, customerId is your customer id as a string (or empty), and exp is a unix-seconds expiry. Use the whole secret string, zsi_ prefix included, as the HMAC key.
  3. Render the result into the page, or dispatch it after login.
javascript
// Node.js (server-side). STORE_ID is on your setup guide.
import crypto from "node:crypto";

export function zubbyIdentity(customer) {
  const email = (customer.email || "").trim().toLowerCase();
  const customerId = String(customer.id || "");
  const exp = Math.floor(Date.now() / 1000) + 30 * 60; // 30 minutes
  const hmac = crypto
    .createHmac("sha256", process.env.ZUBBY_IDENTITY_SECRET)
    .update([process.env.ZUBBY_STORE_ID, customerId, email, exp].join("|"))
    .digest("hex");
  return { email, externalId: customerId, name: customer.name, idToken: "v1." + exp + "." + hmac };
}
php
<?php // PHP (server-side)
$email = strtolower(trim($customer->email));
$customerId = (string) $customer->id;
$exp = time() + 1800; // 30 minutes
$hmac = hash_hmac('sha256', $storeId . '|' . $customerId . '|' . $email . '|' . $exp, $identitySecret);
$identity = ['email' => $email, 'externalId' => $customerId, 'idToken' => 'v1.' . $exp . '.' . $hmac];
python
# Python (server-side)
import hashlib, hmac, time

email = customer.email.strip().lower()
customer_id = str(customer.id)
exp = int(time.time()) + 1800  # 30 minutes
message = "|".join([store_id, customer_id, email, str(exp)]).encode()
signature = hmac.new(identity_secret.encode(), message, hashlib.sha256).hexdigest()
identity = {"email": email, "externalId": customer_id, "idToken": f"v1.{exp}.{signature}"}

Hand it to the widget on every page where the customer is signed in:

html
<script>
  // Server-rendered for signed-in customers only, BEFORE the widget script.
  window.zubbyIdentity = {
    email: "ali@example.com",
    externalId: "cus_123",
    name: "Ali",
    idToken: "v1.1767225600.3f7c0e..."
  };
</script>

<script>
  // Single-page apps: after login (and with a fresh token before it expires)...
  document.dispatchEvent(new CustomEvent("zubby:identify", { detail: identity }));
  // ...and on logout.
  document.dispatchEvent(new CustomEvent("zubby:identify", { detail: null }));
</script>

Keep tokens short-lived

Tokens are rejected once exp passes, and also when exp is more than 24 hours ahead. Thirty minutes is the recommended lifetime; mint a fresh one per page render. A token only verifies for the exact email and customer id it was signed for, and only for your store. Rotating the secret in the dashboard invalidates every token signed with the old one immediately, so deploy the new secret right after rotating. Revoking it switches signed-in features off until you generate a new one.

Pass the widget session to your orders

Once a shopper opens the chat, the widget stores its session id on your storefront under localStorage["salespilot:shopper-session:YOUR_STORE_ID"]. Read it at checkout, carry it to your backend with the order, and send it as shopperSessionId on /api/public/v1/orders: recommendations from that exact chat are then credited to the order, even for a guest who never gave their email in chat. If your storefront has a cart id, hand it to the widget too and send the same value as cartToken.

javascript
// In the browser, at checkout:
const shopperSessionId = localStorage.getItem("salespilot:shopper-session:YOUR_STORE_ID");

// Whenever the cart changes (totalPrice in minor units, e.g. cents):
document.dispatchEvent(new CustomEvent("zubby:cart", {
  detail: { token: cart.id, currency: "USD", itemCount: 2, totalPrice: 4999 }
}));

Checkout links on custom stores

Shopify and WooCommerce have cart-permalink protocols, so the agent can build a one-tap checkout URL. Custom stores have no universal add-to-cart protocol, so the inline-checkout and reorder tools link the shopper to the product page you provided in url (falling back to your store root). Always send a real product url so those links land somewhere useful.

Reading data back out

Read-scoped endpoints let you mirror Zubby data into your systems or expose buy-ready catalog search to an approved server-side agent. Conversations and leads are cursor-paginated - pass the next_cursor from the previous response.

MethodPathReturns
GET/api/public/v1/conversationsShopper ↔ AI sessions with intent, sentiment, summary, tags.
GET/api/public/v1/leadsCaptured leads with email, purpose, status, priority.
GET/api/public/v1/shopStore-scoped product search with price, availability, and product links.

See the REST API reference for the full request/response shape, rate limits, and error format.

Keeping data fresh

  • On change - push a product whenever its price, inventory, or copy changes, and an order whenever its status changes. Single-item pushes are cheap.
  • Nightly full sync - re-push your whole catalog (in batches of ≤200) once a day as a backstop. Idempotency makes this safe and free of duplicates.
  • Search-indexing delay - products become searchable after indexing completes. If a freshly pushed product never appears in search, see products not becoming searchable.

Errors

A malformed body returns 400 with a details field from validation. A missing or wrong scope returns 401/403. When some items in a batch fail but others succeed, you get 200 with the failures listed in errors; when every item fails you get 422. Always inspect errors even on a 200.

Adjacent

Before launch: validate prices and reconcile changes

Send accurate prices and currency for every sellable variant. An omitted price can become zero; check a sample in Catalog and the widget before enabling public traffic or feeds. Keep external IDs stable. A repeat write updates the matching record, so it can replace earlier values. Inspect every per-item error, retry only failed records, and compare the result with your source catalog.

Do not assume leaving a product out of a batch deletes it. Send an explicit supported inactive/status update for unavailable items and confirm they stop appearing in shopper searches. The API reference is the contract for supported fields. Historical orders need their real original timestamp; do not send today’s time to make an old order look new.

Missing something, or found an error on this page?

Contact support and include the URL of this page (/docs/integrations/custom).