ProductsDocsGuidesPricing
Sign inGet Started

Intelligence APIs for developers.

Product

  • All APIs
  • Support Triage
  • Pricing

Developers

  • Getting started
  • Guides
  • API reference
  • Errors
  • Dashboard

Company

  • About
  • Security
  • Contact

Legal

  • Privacy Policy
  • Terms of Service
© Kovca. kovca.io
GuidesCommerce

How to Match Supplier Product Catalogs to Your Own SKUs

A practical guide to matching supplier feeds to your SKUs: blocking candidates, using GTINs safely, handling variants, and routing uncertain matches. Code in curl, TypeScript and Python.

Catalog Matching9 min readcURL · TypeScript · Python
same_variantdifferent_variantuncertain

Every team that onboards supplier feeds ends up writing the same fragile script. A supplier sends a CSV with 40,000 rows; your catalog has 12,000 SKUs; somebody has to decide which supplier row is your "Apple iPhone 16 Pro 256GB Black Titanium". The first version joins on UPC. The second adds fuzzy title matching. The third adds a spreadsheet of exceptions maintained by an ops person who is now the single point of failure.

This guide walks through a matching pipeline that holds up in production. Most of it applies no matter what tools you use. In step 4 we use the Kovca Catalog Matching API for the pairwise decision, but the architecture around it is the part that matters.

Why naive matching breaks

Identifiers lie (a little). GTINs get typoed, suppliers reuse their own codes, and an MPN from one brand can collide with another brand's MPN. A shared barcode is strong evidence, but a barcode on a record whose title says "iPhone 15" while yours says "iPhone 16" is a data problem, not a match.

Variants are the real trap. "Same product" and "same purchasable item" are different questions. A 256GB and a 512GB phone are the same product but different SKUs. Merge them and you sell the wrong thing at the wrong price. And what counts as a variant depends on your business: color matters for apparel, but maybe not for a replacement part sold in "black or grey, our choice".

Formatting noise swamps signal. 256 GB vs 256GB, 15.6 inches vs 15.6in, Acme Inc. vs ACME. Every one of these is a false negative in a string comparison.

Wrong matches cost more than missed ones. A missed match means a human links it later. A wrong match means a mispriced listing, a bad order, or a return. Your pipeline should prefer "I'm not sure" over a confident wrong answer.

The pipeline

  1. 1
    Normalize
    map both catalogs into one record shape
  2. 2
    Block
    find a handful of candidate SKUs cheaply
  3. 3
    Kovca Catalog Matching
    compare the supplier record with each candidate
    Review queuerequires_review, uncertain, or two exact matches
  4. 4
    Route
    auto-link one clean same_variant match
  5. 5
    Remember
    store decisions so a known pair is never re-decided

Steps 1 and 2 happen before any API call, and steps 5 and 6 happen after it. The middle step gets the attention, but most of the quality comes from the steps around it.

1Normalize into one record shape

Pick a canonical shape and map both sides into it. This is the shape the Kovca API accepts, and it works fine as an internal format too:

JSON
{
  "title": "iPhone 16 Pro - Black Titanium - 256 GB",
  "brand": "Apple",
  "model": "iPhone 16 Pro",
  "category": "Smartphones",
  "attributes": { "storage": "256 GB", "color": "Black Titanium" },
  "identifiers": { "gtin": "...", "mpn": "...", "supplier_sku": "NW-88213" }
}

Two rules here save you pain later. First, use the same attribute names on both sides. If your catalog says storage and the supplier says capacity, rename one in your mapping layer. Second, keep the supplier's own SKU, but never treat it as evidence. It's an ID the supplier made up for their own system, so it means nothing about your products.

2Block candidates cheaply

You can't compare 40,000 × 12,000 pairs. Blocking narrows each supplier row to maybe 3 to 10 candidates. A good blocking key uses fields that are cheap and reliable:

  • Exact GTIN/UPC/EAN after stripping whitespace (an instant candidate when present)
  • Lowercased brand plus a model token (apple|iphone 16 pro)
  • Category plus a few distinctive title tokens, using a trigram index or your search engine

Blocking should favor recall: it's fine to pull in some wrong candidates, because the next step rejects them. It's not fine to miss the right one.

Python
from collections import defaultdict

def block_key(rec):
    brand = (rec.get("brand") or "").lower().strip()
    model = (rec.get("model") or "").lower().strip()
    return f"{brand}|{model}" if brand and model else None

index = defaultdict(list)
for sku in our_catalog:
    k = block_key(sku)
    if k:
        index[k].append(sku)

def candidates(supplier_rec):
    k = block_key(supplier_rec)
    return index.get(k, [])[:10]

3Decide which attributes define a variant

Before you compare anything, write down which attributes make a different SKU in your catalog. For phones it's probably ["storage", "color"]. For apparel, ["size", "color"]. For fasteners, maybe ["length", "thread", "pack_size"]. That list becomes the variant_attributes parameter below. If you leave it out, a conflict on any attribute rules out a same-variant match, which is the safe default but stricter than you may want.

4Pairwise decision

This is where a dedicated API earns its keep. The Catalog Matching endpoint takes exactly two records and returns one of four answers: same_variant, same_product_different_variant, different_product, or uncertain. It normalizes case, Unicode, units (256 GB → 256GB) and company suffixes before comparing. It validates GTIN/UPC/EAN check digits and ignores invalid codes. It doesn't trust a shared identifier when the descriptions contradict it. It doesn't look anything up and won't add facts that aren't in your records.

curl

cURL
curl https://kovca.io/api/v1/catalog/match \
  -H "Authorization: Bearer $KOVCA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "product_a": {
      "title": "Apple iPhone 16 Pro 256GB Black Titanium",
      "brand": "Apple",
      "model": "iPhone 16 Pro",
      "attributes": { "storage": "256GB", "color": "Black Titanium" }
    },
    "product_b": {
      "title": "iPhone 16 Pro - Black Titanium - 256 GB",
      "brand": "Apple",
      "model": "iPhone 16 Pro",
      "attributes": { "storage": "256 GB", "color": "Black Titanium" }
    },
    "variant_attributes": ["storage", "color"]
  }'

TypeScript (Node 18+)

TypeScript
type Product = {
  title: string;
  brand?: string;
  model?: string;
  category?: string;
  attributes?: Record<string, string>;
  identifiers?: Partial<Record<
    "gtin" | "upc" | "ean" | "mpn" | "manufacturer_sku" | "supplier_sku" | "model_number",
    string
  >>;
};

type MatchResult = {
  request_id: string;
  match_type: "same_variant" | "same_product_different_variant" | "different_product" | "uncertain";
  requires_review: boolean;
  matched_attributes: string[];
  conflicting_attributes: string[];
  normalized: { brand?: string; model?: string; attributes: Record<string, string> };
};

export async function matchPair(a: Product, b: Product, variantAttributes?: string[]): Promise<MatchResult> {
  const res = await fetch("https://kovca.io/api/v1/catalog/match", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.KOVCA_API_KEY}`,
      "Content-Type": "application/json",
    },
    // Omit an empty list. Without variant_attributes, any attribute conflict rules out same_variant.
    body: JSON.stringify({
      product_a: a,
      product_b: b,
      ...(variantAttributes?.length ? { variant_attributes: variantAttributes } : {}),
    }),
  });
  if (!res.ok) {
    const err = await res.json().catch(() => ({}));
    throw new Error(`${res.status} ${err?.error?.code ?? "UNKNOWN"}: ${err?.error?.message ?? ""}`);
  }
  return res.json();
}

Python

Python
import os, time, requests

def match_pair(a, b, variant_attributes=None, retries=4):
    # Omit an empty list. Without variant_attributes, any attribute conflict rules out same_variant.
    payload = {"product_a": a, "product_b": b}
    if variant_attributes:
        payload["variant_attributes"] = variant_attributes
    for attempt in range(retries):
        r = requests.post(
            "https://kovca.io/api/v1/catalog/match",
            headers={"Authorization": f"Bearer {os.environ['KOVCA_API_KEY']}"},
            json=payload,
            timeout=30,
        )
        if r.status_code == 200:
            return r.json()
        code = r.json().get("error", {}).get("code")
        # Retry only transient errors; USAGE_LIMIT_EXCEEDED is also a 429 but must not be retried
        if r.status_code in (500, 503, 504) or code == "RATE_LIMITED":
            time.sleep(int(r.headers.get("Retry-After", 2 ** attempt)))
            continue
        r.raise_for_status()
    raise RuntimeError("catalog match failed after retries")

The response tells you why as well as what:

JSON
{
  "request_id": "req_6tVb1QmXcR8pLz3K",
  "match_type": "same_variant",
  "requires_review": false,
  "matched_attributes": ["brand", "model", "storage", "color"],
  "conflicting_attributes": [],
  "normalized": { "brand": "Apple", "model": "iPhone 16 Pro",
                  "attributes": { "storage": "256GB", "color": "Black Titanium" } }
}

conflicting_attributes is the most useful field for your reviewers. "storage" in that list tells a human exactly where to look. Note that a field missing from one record is never a conflict.

5Route on the result

Python
def route(supplier_rec, cands, variant_attrs):
    results = [(c, match_pair(supplier_rec, c, variant_attrs)) for c in cands]
    exact = [(c, r) for c, r in results
             if r["match_type"] == "same_variant" and not r["requires_review"]]
    if len(exact) == 1:
        return ("auto_link", exact[0][0]["sku"])
    if any(r["requires_review"] for _, r in results) or len(exact) > 1:
        return ("review", results)          # humans decide
    siblings = [c for c, r in results if r["match_type"] == "same_product_different_variant"]
    if siblings:
        return ("new_variant_of", siblings[0]["sku"])  # maybe create a new SKU
    return ("no_match", None)

Notice the two guards: exactly one clean same_variant, and no requires_review. If two candidates both claim to be exact, your own catalog probably has duplicates, and that's worth surfacing. Treat uncertain as "send to a person", never as "probably the same".

same_product_different_variant is underrated. It's how you discover that a supplier carries a 1TB version you don't list yet, and it can drive assortment decisions as well as linking.

A cheap win: before the first full run, pick 50 supplier rows your team has already matched by hand and run them through the whole pipeline. If blocking misses the right SKU for any of them, fix blocking first. No pairwise decision can recover a candidate that was never compared.

6Remember decisions

Cache every decision keyed on a hash of both normalized records. Supplier feeds arrive weekly and are mostly unchanged, so after the first run you should only be comparing new or changed rows. That keeps cost and rate limits predictable. Kovca doesn't store your records, so persistence is your job.

Cost and throughput, honestly

Requests are pairwise, so cost scales with candidates per row × changed rows. Tight blocking (3 to 5 candidates) and caching matter more than anything else. On Kovca's Free plan you get 1,000 successful requests a month at 10 requests per minute, which is enough to validate the approach on a real sample. Pay As You Go is $0.005 per successful request at 60 per minute, with a spending cap you set (pricing). Throttle your workers to the rate limit and back off on 429 RATE_LIMITED using Retry-After (errors and limits).

Known limits to design around

  • No unit conversion: 1 TB and 1000 GB aren't treated as equal. Normalize these in your mapping layer if they matter.
  • Color marketing names (Midnight vs Black) aren't reconciled.
  • Two records per request; there's no catalog search or storage. Blocking is yours.

Try it on your own data

The fastest way to see whether this works for your catalog is to take 20 pairs your team has already argued about and run them through. Create a free account (no card required), generate an API key, and paste a pair into the playground. Then read the full contract in the Catalog Matching docs.

On this page

  • Why naive matching breaks
  • The pipeline
  • Cost and throughput, honestly
  • Known limits to design around
  • Try it on your own data

Catalog Matching

Decide whether two product records are the same item, a different variant, or a different product.

Try it in the playground

Keep going

Catalog MatchingAPI reference
approvetroubleshootmanual_review
Returns Intelligence7 min read

Screening Return Requests Against Your Return Policy

Put a policy check in front of your returns queue: compute eligibility from real order data, auto-approve the clear cases, and route exceptions to people. Code in curl, TypeScript and Python.

Read guide
billingtechnicalneeds_review
Support Triage7 min read

Automating Support Ticket Triage in Your Helpdesk Pipeline

Build a support ticket triage pipeline: webhook intake, category and priority classification, human-review routing, and safe fallbacks. Code in curl, TypeScript and Python.

Read guide
monitorcontact_carrierinvestigate
Shipment Intelligence6 min read

Catching Delivery Exceptions Before Your Customers Do

Turn raw carrier tracking events into routed delivery exceptions: late and stalled parcels, address problems, losses and disputes. A scheduled pipeline with curl, TypeScript and Python.

Read guide