Bowmark AIdocs

Gymshark US

Gymshark US — Every Shopify storefront, reached by its own name.

Gymshark US — Every Shopify storefront, reached by its own name. One engine over the platform's two published surfaces — the store's own UCP MCP server at /api/ucp/mcp for search, and the keyless Ajax storefront JSON for products and the cart.

Domain: gymshark.com

Also known as: gymshark, Gymshark US

Call it directly

bowmark.providers.gymshark.search(query: string, opts?: { productType?: string; inStockOnly?: boolean; limit?: number }): Promise<ShopifyProduct[]>
bowmark.providers.gymshark.getProduct(handle: string): Promise<ShopifyProduct>
bowmark.providers.gymshark.resolveProductUrl(url: string): Promise<ShopifyProductFromUrl>
bowmark.providers.gymshark.getProducts(handles: string[]): Promise<ShopifyProductBatch>
bowmark.providers.gymshark.listProducts(opts?: { limit?: number; cursor?: string | null }): Promise<ShopifyProductPage>
bowmark.providers.gymshark.listCollections(opts?: { limit?: number }): Promise<ShopifyCollection[]>
bowmark.providers.gymshark.getCollection(handle: string, opts?: { limit?: number; cursor?: string | null }): Promise<ShopifyCollectionProducts>
bowmark.providers.gymshark.getSetEvidence(handle: string, opts?: { includeAlgorithmic?: boolean }): Promise<ShopifySetEvidence>
bowmark.providers.gymshark.addToCart(items: Array<{ variantId: string; quantity?: number }>): Promise<ShopifyCart>
bowmark.providers.gymshark.getCart(): Promise<ShopifyCart>

Functions

FunctionWhat it does
searchSearches Gymshark US's live catalogue and returns matching products with their real variants, current prices and per-variant stock.
getProductReads one Gymshark US product by its handle — every variant, its exact price, its SKU and whether that specific size or colour is purchasable right now.
resolveProductUrlTurns a Gymshark US product URL into the product, which is what you have when a link arrives from a search result, a page you read, or a person.
getProductsReads FULL detail for many Gymshark US products in one call — the shape to use when ranking a candidate set, because a search row carries neither the description copy nor the per-variant…
listProductsWalks Gymshark US's WHOLE catalogue a page at a time, in the store's own merchandised order — the shape for assembling a realistic candidate set, which search cannot do because it ranks…
listCollectionsLists Gymshark US's own merchandised collections — handle, title, blurb and product count.
getCollectionReads one Gymshark US collection's products, in the RETAILER's own merchandised order, as full ShopifyProduct rows.
getSetEvidenceTwo answers about what goes with this Gymshark US product, in one call. evidence is ONLY what a MERCHANDISER pinned by hand, in the order they typed it — an explicit retailer statement, and…
addToCartPuts variants into THIS run's own cart on gymshark.com and returns the cart as the store reports it.
getCartReads THIS run's cart back from gymshark.com — lines, quantities, per-line and order totals, and the real cart and checkout URLs.

Types

interface ShopifyVariant {
  /** Shopify's numeric variant id as a string. What addToCart takes. */
  id: string;
  /** The variant's own label, e.g. "Black / S". */
  title: string;
  /** Decimal string exactly as the store publishes it, e.g. "45.00". */
  price: string;
  compareAtPrice: string | null;
  sku: string | null;
  /** The store's own per-variant stock flag. */
  available: boolean;
  options: string[];
  /** The same values keyed by the option's own NAME — { Color: "Black", Size: "S" }.
   * Read THIS to filter by size; options[1] is only the size on a store that
   * happens to order it second. {} when the two lists cannot be reconciled. */
  selectedOptions: Record<string, string>;
  /** The image the STORE linked to this variant. Usually NULL — most storefronts
   * publish no image-to-variant link at all, and null says so rather than
   * handing back the first product picture. */
  image: ProductImage | null;
  /** Why this price is, or is not, a markdown. */
  sale: SaleEvidence;
}
interface ProductImage {
  /** Absolute HTTPS url, at the largest rendition the CDN serves. */
  url: string;
  /** The retailer's own alt text, or null. */
  altText: string | null;
  /** The retailer's colour name for this picture. Set when the store links it,
   * or when the whole product is ONE colourway (which is how both yoga
   * retailers publish: one colour per product). Null otherwise. */
  color: string | null;
  colorId: string | null;
  /** Variant ids the STORE linked. [] means it published no link — NOT that the
   * image belongs to every variant. */
  variantIds: string[];
}
interface SaleEvidence {
  /** True only with retailer evidence. NEVER set from a low price or from the
   * word "sale" in a title. */
  onSale: boolean;
  currentPrice: string | null;
  /** The published "was" price. Strictly greater than currentPrice when
   * evidenceType is "compare_at_price". Null when nothing published one. */
  originalPrice: string | null;
  /** ISO 4217, from the storefront's own /meta.json. Null only when that read
   * failed — never defaulted to "USD", which is wrong on every non-US store. */
  currency: string | null;
  promotionMessage: string | null;
  /** How it was decided. "compare_at_price" is a struck-through price;
   * "retailer_sale_badge" is a published sale tag; "none" is not on sale. */
  evidenceType: "compare_at_price" | "sale_price" | "retailer_sale_badge" | "published_promotion" | "none";
  /** The retailer text or field the classification rests on, verbatim. */
  evidenceText: string | null;
}
/** The retailer's own labels, read off its published tags, category and options.
 * NEVER inferred from the title or from an image. Null or [] when unpublished,
 * which is common and is an honest answer. */
interface PublishedProductAttributes {
  audience: string | null;
  garmentType: "sports_bra" | "tank" | "crop_top" | "leggings" | "shorts" | "other" | null;
  categories: string[];
  collections: string[];
  fabrics: string[];
  materials: string[];
  color: string | null;
  colorFamily: string | null;
  pattern: string | null;
  styleTags: string[];
  neckline: string | null;
  strapWidth: string | null;
  backDesign: string | null;
  sleeveLength: string | null;
  rise: string | null;
  waistband: string | null;
  /** How long the GARMENT is — "Cropped", "Midi". What every store's own
   * `length::` tag fills, on a top exactly as on a bottom. */
  garmentLength: string | null;
  /** The inside leg seam and only that. NULL on a top — a tank has no inseam,
   * and its length is `garmentLength`. */
  inseam: string | null;
  legShape: string | null;
  fit: string | null;
  coverage: string | null;
}
/** Shared colour, fabric, collection and style family — what makes two garments
 * PLAUSIBLY coordinate. None of it establishes a set. */
interface CoordinationMetadata {
  collectionNames: string[];
  fabricNames: string[];
  colorName: string | null;
  colorId: string | null;
  colorFamily: string | null;
  /** The store's own grouping key for one style across its colourways. Two
   * products sharing it are the same garment in two colours. */
  productFamily: string | null;
}
/** An EXPLICIT retailer-published relationship. Shared colour, fabric,
 * collection or family is NOT this — that is CoordinationMetadata. Usually [].
 *
 * Two doors fill it. On a PRODUCT row it comes from the store's tags, and no
 * store measured publishes a set tag, so it is [] there. getSetEvidence() reads
 * the other one: the complementary products a MERCHANDISER pinned by hand,
 * admitted only for rows the store marks pr_prod_strat=pinned. The algorithmic
 * "related products" feed is never read into this — that is a recommendation
 * engine's output, not the retailer stating a pairing. */
interface RetailerSetEvidence {
  evidenceType: "official_set" | "shop_the_set" | "complete_the_look" | "matching_piece";
  evidenceText: string | null;
  sourceUrl: string | null;
  setId: string | null;
  relatedProducts: Array<{ productId: string | null; handle: string | null; title: string | null; url: string | null }>;
}
/** What getSetEvidence returns. An OBJECT rather than a bare array so the
 * healthy EMPTY answer is still probeable: productId and sourceUrl exist only
 * when BOTH hops answered, where an empty evidence list is the ordinary case. */
interface ShopifySetEvidence {
  handle: string;
  /** Shopify's numeric PRODUCT id — the only key the recommendations door takes,
   * and published by no other function here. */
  productId: string;
  /** The product page the pairing is published on. */
  sourceUrl: string;
  /** ONLY the rows a merchandiser pinned by hand. [] when they pinned nothing,
   * which is the common case and is the STORE's answer. At most one entry — one
   * statement, N related products. Read this for "what did the retailer SAY". */
  evidence: RetailerSetEvidence[];
  /** EVERY row the store recommends, pinned AND algorithmic, deduplicated, each
   * labelled. Read source.by before treating one as the retailer's decision —
   * the algorithmic rows are usually SUBSTITUTES rather than companions, since
   * similarity returns the nearest product and the nearest thing to a baby
   * monitor is another baby monitor. [] when includeAlgorithmic was false. */
  recommendations: RecommendedProduct[];
  /** Non-empty when the store returned a FULL page for an intent, which cannot be
   * told apart from a longer list cut off at the cap. A merchandiser's pairing is
   * a statement, so a partial one must not read as the whole. */
  warnings: string[];
}
/** One recommended product, with the store's own label for who chose it. */
interface RecommendedProduct {
  productId: string | null;
  handle: string | null;
  title: string | null;
  url: string | null;
  source: RecommendationSource;
}
interface RecommendationSource {
  /** "retailer" when a merchandiser pinned it; "algorithm" otherwise, INCLUDING
   * a row the store labelled with nothing — an unknown provenance must never
   * read as a person's decision. */
  by: "retailer" | "algorithm";
  /** The store's own token, verbatim and unmapped — "pinned", "jac" (Jaccard),
   * "e" (embedding), "collection_fallback". Null when the row carried none. */
  strategy: string | null;
}
interface ShopifyProduct {
  handle: string;
  title: string;
  productType: string;
  vendor: string;
  url: string;
  optionNames: string[];
  variants: ShopifyVariant[];
  priceRange: { min: string; max: string } | null;
  inStock: boolean;
  tags: string[];
  descriptionHtml: string | null;
  /** The same copy with its markup removed. */
  descriptionText: string | null;
  /** ISO 4217, read once per store from its own /meta.json. */
  currency: string | null;
  /** When the store's door ANSWERED this row, ISO 8601 UTC. Every field here is
   * a live fact with a shelf life — the price, the markdown, the per-variant
   * stock flag — so read it before treating a cached row as current. Stamped per
   * REQUEST: a getProducts batch carries one stamp per handle, not one per call. */
  fetchedAt: string;
  /** Every image the door published, deduplicated, in the store's own order. */
  images: ProductImage[];
  attributes: PublishedProductAttributes;
  /** Per-FIELD origin for `attributes` — keyed by the same field names. Read it
   * before ranking on a null: "absent" is the store publishing nothing, and is
   * the store's own answer; "unreachable" is a door we could not read, and means
   * UNKNOWN. On this store both doors are one request that either answered or
   * threw, so nothing here is ever "unreachable" — the status exists because the
   * same vocabulary is used by providers whose page can refuse mid-answer. */
  attributeProvenance: Record<string, FieldProvenance>;
  attributeCompleteness: Completeness;
  coordination: CoordinationMetadata;
  retailerSetEvidence: RetailerSetEvidence[];
}
/** Where one attribute value came from, and the retailer text behind it. */
interface FieldProvenance {
  /** "published" — the retailer stated it. "absent" — every door was silent.
   * "unreachable" — a door that would carry it was refused, so it is UNKNOWN. */
  status: "published" | "absent" | "unreachable";
  /** Which door: "shopify_tags", "shopify_product_type", "shopify_color_option"
   * or "shopify_product_copy". Null when nothing filled it. */
  source: string | null;
  /** The retailer's own words the value rests on, verbatim — the tag, or the
   * sentence out of the description. Null when nothing filled it. */
  evidence: string | null;
  /** On "unreachable" only: what was refused. */
  detail?: string;
}
/** The roll-up over one product's attributeProvenance. */
interface Completeness {
  fields: number;
  published: number;
  absent: number;
  unreachable: number;
  /** published / fields, to 3dp. */
  ratio: number;
  /** The fields whose value is UNKNOWN rather than known-empty. Read this before
   * comparing two rows: a row with entries here was not fully looked at. */
  unreachableFields: string[];
  sourcesUsed: string[];
}
/** What getProducts returns. PARTIAL by design: one handle the store will not
 * serve costs that row and nothing else, where getProduct throws. */
interface ShopifyProductBatch {
  /** In the order the handles were passed, not the order they finished. */
  products: ShopifyProduct[];
  /** Every handle the store did not serve, with what it said. */
  missing: Array<{ handle: string; detail: string }>;
  requested: number;
  warnings: string[];
}
interface ShopifyCartLine {
  /** Shopify's own line key, which its cart-change endpoints address a line by. */
  lineKey: string;
  variantId: string;
  productTitle: string;
  /** Null on a single-variant product. */
  variantTitle: string | null;
  quantity: number;
  price: string;
  lineTotal: string;
  url: string;
}
/** One collection the store publishes — its own merchandised grouping, and the
 * ONE place either yoga retailer states a set. Membership in a collection the
 * retailer NAMED "Matching Sets" is a published fact and is admissible as set
 * evidence; two products being adjacent inside it is NOT, because a position in
 * a list is not a statement. */
interface ShopifyCollection {
  handle: string;
  title: string;
  url: string;
  descriptionHtml: string | null;
  /** True when the retailer's OWN title or handle names this a set, a matching
   * piece or a look. Never inferred from what is inside it. */
  setLike: boolean;
  productCount: number | null;
  updatedAt: string | null;
}
interface ShopifyCollectionProducts {
  handle: string;
  url: string;
  /** In the RETAILER's own order. Curated collections are merchandised
   * top-then-bottom; reading a pairing out of that order is the caller's
   * inference, never this provider's claim. [] is an ordinary answer. */
  products: ShopifyProduct[];
  /** Pass back as opts.cursor for the next page, or NULL when this is the last
   * one. A collection bigger than one page is reachable only through this. */
  cursor: string | null;
  /** Empty on an ordinary page. One entry when the walk hit the store platform's
   * 25,000-row ceiling with the collection unfinished — a TRUNCATION, which a
   * null cursor on its own would read as the end of the list. */
  warnings: string[];
}
/** One page of a whole-catalogue walk. Advance it with the cursor; there is
 * deliberately no "fetch everything" call, because every row is a request
 * against the store and only the caller knows how many candidates it needs. */
interface ShopifyProductPage {
  /** In the store's own MERCHANDISED order — not id, not date. The store may
   * re-merchandise mid-walk, so key on handle rather than assuming pages are
   * disjoint. */
  products: ShopifyProduct[];
  /** Pass back as opts.cursor for the next page. NULL when the store answered a
   * short page, which is what the end of the catalogue looks like. */
  cursor: string | null;
  /** How many rows this page asked the store for. */
  limit: number;
  /** Empty on an ordinary page. One entry when the walk stopped at the 25,000-row
   * ceiling with the catalogue unfinished. */
  warnings: string[];
}
/** What resolveProductUrl hands back — the product a url names, and the variant
 * its own ?variant= selected. */
interface ShopifyProductFromUrl {
  product: ShopifyProduct;
  /** The variant ?variant= named, or NULL when the url named none — the ordinary
   * case for a link off a collection page. Also null when it named one the store
   * no longer publishes, which warnings says. NEVER the first variant instead:
   * that answers "is my size in stock" about a different size. */
  variant: ShopifyVariant | null;
  /** The ?variant= value exactly as the url carried it, kept even when it
   * matched nothing — a stale link is a fact about the link. */
  variantIdInUrl: string | null;
  /** Empty on a clean resolve. One entry when the url named a variant the store
   * no longer publishes. */
  warnings: string[];
}
interface ShopifyCart {
  /** The store's own cart token — a bearer credential, so treat it like one. */
  token: string;
  lines: ShopifyCartLine[];
  /** Units, NOT lines: two of one variant is one line and two items. */
  itemCount: number;
  subtotal: string;
  currency: string;
  cartUrl: string;
  checkoutUrl: string;
}

Examples

// Everything published about one product: the size a shopper can actually
// buy, the picture for that colour, and WHY a price is a markdown.
const [hit] = await bowmark.providers.gymshark.search("leggings", { inStockOnly: true, limit: 1 });
if (!hit) return { note: "nothing matched" };
const p = await bowmark.providers.gymshark.getProduct(hit.handle);
const small = p.variants.find((v) => v.selectedOptions.Size === "S" && v.available);
return {
  title: p.title,
  // The retailer's OWN labels. null/[] means it published none — never inferred.
  garment: p.attributes.garmentType,      // "leggings" | "sports_bra" | … | null
  fabric: p.attributes.fabrics,           // ["Spacedye"] on Beyond Yoga, [] elsewhere
  colour: p.attributes.color,
  small: small && {
    variantId: small.id,                  // the ONLY thing addToCart takes
    price: small.price,
    onSale: small.sale.onSale,
    why: small.sale.evidenceType,         // "compare_at_price" | "retailer_sale_badge" | "none"
    was: small.sale.originalPrice,        // null unless the store published one
    proof: small.sale.evidenceText,
  },
  // Absolute https. `color` is set when the store links it OR when the whole
  // product is one colourway; `variantIds: []` means NO published link — it does
  // NOT mean the picture is every variant's.
  photos: p.images.map((i) => ({ url: i.url, color: i.color, variantIds: i.variantIds })),
};
// Coordinating two pieces, WITHOUT pretending the store sold them as a set.
const tops = await bowmark.providers.gymshark.search("tank", { inStockOnly: true, limit: 5 });
const bottoms = await bowmark.providers.gymshark.search("leggings", { inStockOnly: true, limit: 5 });
// THE STRONGEST claim, and the only one that is the retailer SPEAKING: what a
// merchandiser pinned to this product by hand. Its own call, and [] on most
// products, which is the store's answer rather than a failure.
const set = tops[0] && (await bowmark.providers.gymshark.getSetEvidence(tops[0].handle));
const pinned = set ? set.evidence : [];   // [] means the merchandiser pinned nothing
// Everything the store recommends, labelled. NEVER treat a row as the retailer's
// decision without reading source.by — the "algorithm" rows are usually
// SUBSTITUTES (a baby monitor's nearest neighbour is another baby monitor).
const alternatives = (set ? set.recommendations : []).filter((r) => r.source.by === "algorithm");
// The product rows carry the TAG-derived half of the same field, which is []
// on every store measured because no store publishes a set:: tag.
const fromTags = [...tops, ...bottoms].flatMap((p) => p.retailerSetEvidence);
// coordination is the weaker, honest signal: shared colour, fabric, collection,
// and the store's own style family. Weigh it; do not report it as a set.
const pairs = tops.flatMap((t) =>
  bottoms
    .filter((b) => b.coordination.colorFamily && b.coordination.colorFamily === t.coordination.colorFamily)
    .map((b) => ({ top: t.title, bottom: b.title, sharedColorFamily: t.coordination.colorFamily })),
);
// pinned[0].relatedProducts is what the retailer SAID. pairs is what we matched.
return { pinned, alternatives, fromTags, coordinated: pairs };