BOSSTORQUE · Internal Tech Doc

eBay Browse API — Headless Access

The sanctioned, default method for any eBay listing fetch, price comparison, or search task at BOSSTORQUE. Server-to-server OAuth2 + modern Browse API. No browser scraping. No deprecated Finding API. Credentials never leave the Cloudflare Worker.

PRODUCTION ACTIVE EBAY_US Marketplace App: always-pay-less

Why this exists

The old approach scraped eBay search HTML or hit the deprecated Finding API (svcs.ebay.com). Both are dead ends. The Browse API is what eBay's own partner integrations use — richer data in one call, with proper authentication. This doc locks in the architecture so every future task and code build starts from the same place.

The one-line summary

Worker hits api.ebay.com/identity/v1/oauth2/token with Basic auth (App ID + Cert ID), caches the bearer token for ~2 hours, then GETs api.ebay.com/buy/browse/v1/item_summary/search with that token and X-EBAY-C-MARKETPLACE-ID: EBAY_US. Credentials live as Cloudflare Pages secrets on always-pay-less, never in source or frontend.

Hard rules

Credentials & environment

FieldValue
App ID (Client ID)BOSSTORQ-alwayspa-PRD-56c1d3885-30e85485
Cert ID (Client Secret)Stored as EBAY_CLIENT_SECRET Pages secret. Local reference at ~/Documents/.claude/memory/context/api-keys.md.
EnvironmentProduction (PRD)
MarketplaceEBAY_US
Scopehttps://api.ebay.com/oauth/api_scope
OAuth endpointhttps://api.ebay.com/identity/v1/oauth2/token
Browse search endpointhttps://api.ebay.com/buy/browse/v1/item_summary/search
CF Account ID8cef3a20d2c22491d2bbbc594cf4865d
CF Pages projectalways-pay-less
Worker file~/Documents/GitHub/always-pay-less/public/_worker.js

Setting Pages secrets

If keys ever roll or you spin up a new Pages project that needs eBay access, set the secrets via wrangler. Never paste them into source.

npx wrangler pages secret put EBAY_CLIENT_ID --project-name always-pay-less
npx wrangler pages secret put EBAY_CLIENT_SECRET --project-name always-pay-less

Confirm they're set in the right scope:

npx wrangler pages secret list --project-name always-pay-less

Auth call (cache the token)

let _ebayTokenCache = null;

async function getEbayToken(env) {
  const now = Date.now();
  if (_ebayTokenCache && _ebayTokenCache.expiresAt > now + 60_000) {
    return _ebayTokenCache.token;
  }
  const basic = btoa(`${env.EBAY_CLIENT_ID}:${env.EBAY_CLIENT_SECRET}`);
  const r = await fetch("https://api.ebay.com/identity/v1/oauth2/token", {
    method: "POST",
    headers: {
      "Authorization": `Basic ${basic}`,
      "Content-Type": "application/x-www-form-urlencoded"
    },
    body: "grant_type=client_credentials&scope=" +
          encodeURIComponent("https://api.ebay.com/oauth/api_scope")
  });
  if (!r.ok) throw new Error(`eBay OAuth ${r.status}`);
  const data = await r.json();
  _ebayTokenCache = {
    token: data.access_token,
    expiresAt: now + (data.expires_in - 300) * 1000
  };
  return data.access_token;
}

Search call (with 401 retry)

async function searchEbayBrowse(env, query, opts = {}) {
  const { limit = 30, conditions = ["NEW"], priceMin = 10 } = opts;
  const token = await getEbayToken(env);
  const filter = [
    `price:[${priceMin}..]`,
    `priceCurrency:USD`,
    `conditions:{${conditions.join("|")}}`
  ].join(",");
  const url = `https://api.ebay.com/buy/browse/v1/item_summary/search`
            + `?q=${encodeURIComponent(query)}&limit=${limit}`
            + `&filter=${encodeURIComponent(filter)}`;
  let r = await fetch(url, {
    headers: {
      "Authorization": `Bearer ${token}`,
      "X-EBAY-C-MARKETPLACE-ID": "EBAY_US"
    }
  });
  // Token expired mid-flight — refresh once and retry
  if (r.status === 401) {
    _ebayTokenCache = null;
    const fresh = await getEbayToken(env);
    r = await fetch(url, {
      headers: {
        "Authorization": `Bearer ${fresh}`,
        "X-EBAY-C-MARKETPLACE-ID": "EBAY_US"
      }
    });
  }
  if (!r.ok) return []; // Quota exhausted or unrecoverable
  const data = await r.json();
  return (data.itemSummaries || []).map(it => ({
    title: it.title,
    price: parseFloat(it.price?.value || "0"),
    shipping: parseFloat(it.shippingOptions?.[0]?.shippingCost?.value || "0"),
    total: parseFloat(it.price?.value || "0") +
           parseFloat(it.shippingOptions?.[0]?.shippingCost?.value || "0"),
    condition: normalizeEbayCondition(it.condition, it.conditionId),
    seller: it.seller?.username,
    feedbackScore: it.seller?.feedbackScore,
    feedbackPct: it.seller?.feedbackPercentage,
    url: it.itemWebUrl,
    location: it.itemLocation?.country,
    image: it.image?.imageUrl
  }));
}

Condition ID reference

IDLabelNormalized
1000NewNEW
1500New other (open box)NEW_OTHER
2000Certified pre-ownedCPO
2500Seller refurbishedSELLER_REFURB
2750Like newVERY_GOOD
3000UsedUSED_GOOD
4000Very goodVERY_GOOD
5000GoodUSED_GOOD
6000AcceptableUSED_ACCEPTABLE
7000For partsFOR_PARTS

Quick CLI test

If you need to verify the keys are alive without touching the worker, run this from a terminal where the credentials are exported as env vars:

curl -s -X POST -u "$EBAY_CLIENT_ID:$EBAY_CLIENT_SECRET" \
  "https://api.ebay.com/identity/v1/oauth2/token" \
  -d "grant_type=client_credentials&scope=https://api.ebay.com/oauth/api_scope" | jq .access_token

If that returns a long bearer token string, auth is healthy. If it returns an error, check that the keys haven't rolled and that the keyset is enabled in the eBay Developer dashboard.

Diagnostics — when eBay rows come back empty

  1. Check Worker logs for eBay OAuth 401 or HTTP 429 (quota).
  2. Verify the Pages secrets are set in the Production scope: npx wrangler pages secret list --project-name always-pay-less.
  3. Visit the eBay Developer dashboard — check today's call count against the 5000 quota.
  4. Run the curl test above to confirm OAuth works outside the Worker.
  5. Confirm the keyset is enabled (when first added, the keyset is disabled by default — see the eBay dev-relations confirmation email).

Why this is the right model

Server-to-server means the credentials never leave the Cloudflare Worker. The browser sees only the JSON results — never the API key, never the bearer token. The user never authenticates anything; we're hitting eBay's public listing data as a registered developer. Same architecture eBay uses for partner integrations.

The Browse API also returns far more useful fields than the old Finding API in a single call: seller usernames, feedback scores, shipping breakdown, condition enum, listing URLs, location, watchers. That's why falling back to HTML scraping should only happen when the API is genuinely down — the API is just better.

Open improvement

The current always_pay_less worker doesn't yet have the 401-retry block from the snippet above. If the cached token times out mid-search, the request 401s and the user sees zero eBay results until the next Worker isolate cold-start. Patch this next time the worker is touched.

Cross-references