Skip to content

Headless WooCommerce

This connects headless WooCommerce to samesake using the wc/v3 REST API: paginate the catalog once, then keep it current with product webhooks. Base URL: https://<store>/wp-json/wc/v3/.

GET /products returns name, slug, description, short_description, sku, price, stock_status, categories, tags, images, attributes, type. Monetary values are strings; descriptions are HTML — strip before indexing.

catalog.ts
import { collection, f, Channels } from "@samesake/core";
export const products = collection("products", {
fields: {
title: f.text({ searchable: true }), // <- Woo "name"
description: f.text({ searchable: true }),
slug: f.text({ filterable: true }),
category: f.text({ filterable: true }),
price: f.number({ filterable: true, budget: true }),
available: f.boolean({ filterable: true }), // stock_status === "instock"
},
indexing: {
surfaces: {
embed_doc: {
kind: "dense",
embedding: "doc",
build: ({ data }) => `${data.title} ${data.description} ${data.category}`.trim(),
},
fts_doc: {
kind: "fts",
build: ({ data }) => `${data.title} ${data.description} ${data.category}`.trim(),
},
},
gate: ({ data }) => (data.title ? { index: true } : { index: false, reason: "missing-title" }),
},
embeddings: { doc: { model: "your-model", dim: 1536 } },
search: {
channels: [
Channels.fts({ fields: ["title", "description", "category"], weight: 1 }),
Channels.cosine({ embedding: "doc", weight: 1 }),
],
combiner: "rrf",
},
});

WooCommerce admin → Settings → Advanced → REST API → Add key, Read permission. You get ck_... / cs_.... Over HTTPS, use HTTP Basic (key = user, secret = password):

Terminal window
curl https://store.example/wp-json/wc/v3/products -u ck_xxx:cs_xxx

per_page max is 100; total pages come from the X-WP-TotalPages response header. Order by a stable key to avoid skips on a mutating catalog.

bulk-index.ts
import type { SamesakeBundle } from "@samesake/postgres";
async function bulkIndex(base: string, auth: string, app: SamesakeBundle) {
let page = 1, totalPages = 1;
do {
const res = await fetch(`${base}/products?status=publish&per_page=100&orderby=id&order=asc&page=${page}`, {
headers: { Authorization: `Basic ${auth}` },
});
totalPages = Number(res.headers.get("X-WP-TotalPages") ?? 1);
const products = await res.json();
await app.enrich.upsert(products.map(toDoc));
page++;
} while (page <= totalPages);
await app.enrich.enrich();
}

For variable products (type: "variable"), the parent price is only a range — fetch GET /products/<id>/variations and flatten size/price/stock into the doc (or one doc per variation).

Create webhooks (UI: Settings → Advanced → Webhooks, or POST /webhooks) for topics product.created, product.updated, product.deleted pointing at one HTTPS endpoint with a strong shared secret.

The X-WC-Webhook-Signature header is Base64(HMAC-SHA256(raw body, secret)). Hash the raw bytes as received, constant-time compare, ACK 2xx fast (Woo retries and auto-disables after repeated failures), then process. Source: WooCommerce webhooks.

webhooks/woo.ts
import crypto from "node:crypto";
export function verify(rawBody: Buffer, sigHeader: string, secret: string) {
const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("base64");
const a = Buffer.from(sigHeader), b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// after verify + res.sendStatus(200):
const topic = req.get("X-WC-Webhook-Topic"); // product.updated
const payload = JSON.parse(rawBody.toString("utf8"));
if (topic === "product.deleted") {
await app.enrich.remove([String(payload.id)]);
} else {
await app.enrich.upsert([toDoc(payload)]);
await app.enrich.enrich();
}

A *.deleted payload carries only { id }. On product.updated for a variable product, re-fetch its variations.

  1. Read-only REST key; store on HTTPS so Basic auth works.
  2. Backfill: paginate /products?status=publish&per_page=100 to X-WP-TotalPages; flatten variations; strip HTML.
  3. Webhooks: product.created|updated|deleted → verify raw-body HMAC → upsert/enrich or soft-delete.
  4. Backstop: nightly reconciliation with ?modified_after=<ISO8601> to repair missed events.

The runnable, tested version of this guide lives in examples/connectors — deterministic stubs, no API key. Run bun test (Postgres-backed examples need SAMESAKE_DATABASE_URL).