The Product API

The developer reference for a Product API source — the JSON your shop serves for us to read, and the endpoint your shop pushes changes to as they happen.

Overview

A Product API source works in two directions, and you can use either or both:

  • Pull — we send a GET to an address you own, on the schedule set on the source, and you return your whole catalogue.
  • Push — you POST changed products to us the moment they change.

The dashboard's API tab carries this same reference with your brand's real addresses already filled in, so start there when you are wiring it up.

Everything below applies to Product API sources only. A CSV feed is a plain Google Shopping file — it has no push endpoint, no secret and no custom fields.

Pull — we read your address

The request we send

A single GET. There is no pagination: return everything in one response.

HeaderValue
Acceptapplication/json
User-AgentYapletBot/1.0 (+https://yaplet.com)
SignatureThe SHA-256 hash of your shared secret, lowercase hex

Plus any extra headers you configured on the source.

Signature is a plain hash of the shared secret — not an HMAC over the body, and it does not change between requests. Verify it by hashing your own copy of the secret and comparing. Treat it as a bearer token protected by TLS, not as a request signature: it cannot detect replay or tampering.

Your address must use https, on the first request and on every redirect. Private and internal addresses are refused.

What you return

There is one shape, whether or not your products have versions. A product that sells in several flavors, sizes or colors carries a variants list; a product that does not carries its own price and availability instead.

{
  "products": [
    {
      "id": "whey-gold",
      "title": "Whey Gold protein powder",
      "link": "https://shop.example.com/products/whey-gold",
      "description": "Whey protein concentrate with added digestive enzymes.",
      "image": "https://shop.example.com/images/whey-gold.jpg",
      "brand": "Acme Nutrition",
      "category": "Protein > Whey",
      "priority": 820,
      "attributes": {
        "Ingredients": ["whey protein concentrate (milk)", "cocoa"],
        "Serving": "1 scoop (32 g) a day"
      },
      "search_terms": ["whey", "protein shake"],
      "rule_keys": ["whey protein"],
      "variants": [
        {
          "id": "10803",
          "options": { "Flavor": "Pistachio", "Size": "1 kg" },
          "price": "39.90 EUR",
          "availability": "in stock",
          "link": "https://shop.example.com/products/whey-gold?variant=10803",
          "attributes": {
            "Nutrition per serving": { "Energy": "128 kcal", "Protein": "24 g" }
          },
          "priority": 950,
          "rule_keys": ["pistachio"]
        },
        {
          "id": "10804",
          "options": { "Flavor": "Pistachio", "Size": "2.3 kg" },
          "price": "79.90 EUR",
          "sale_price": "69.90 EUR",
          "availability": "out of stock"
        }
      ]
    },
    {
      "id": "SKU-1001",
      "title": "Vitamin C 1000 mg, 60 tablets",
      "link": "https://shop.example.com/products/vitamin-c-1000",
      "description": "High-dose vitamin C in an easy-to-swallow tablet.",
      "price": "12.90 EUR",
      "sale_price": "9.90 EUR",
      "availability": "in stock",
      "brand": "Acme Health",
      "category": ["Vitamins", "Vitamin C"],
      "attributes": {
        "Ingredients": ["ascorbic acid", "rose hip extract"],
        "Form": "tablet"
      },
      "rule_keys": ["ascorbic acid"],
      "rules": ["Vitamin C contributes to the normal function of the immune system."]
    }
  ]
}
A feed written before versions existed is still a valid feed. A product with no variants is stored as a product with exactly one version, carrying the price and availability you sent on the product itself. You do not have to change anything to keep working — adding variants is how you get grouped flavors and sizes when you want them.
A pull is always a full snapshot. Any product of this source that is missing from your response is deleted. If you want to send only changes, use push.

Timing and failure

Three attempts per read (30 seconds each, waiting 5 then 15 seconds), but only for timeouts, network errors and 5xx. A 4xx fails immediately. The response body may be up to 50 MB and contain at most 20,000 products.

Returning zero products counts as a failure, so a broken endpoint can never silently empty the catalogue.

The product object

Required

id
string or number required
Your stable identifier. 1–200 characters. Numbers are accepted and stored as text — so 1001 and "1001" are the same product.
title
string required
1–500 characters.
link
string required
The product page. Must be an http or https URL, up to 2048 characters.

Optional

description
string
Up to 20,000 characters. Indexed in full and shown to the AI in full — there is no shorter searchable excerpt any more.
image
string
An http or https URL.
price
string or number
Free text, up to 100 characters — "12.90 EUR" is fine. Used only when the product has no variants.
sale_price
string or number
Same rules as price. Used only when the product has no variants.
availability
string
Up to 100 characters. Used only when the product has no variants.
brand
string
Up to 200 characters.
category
string or string[]
The product's category as a path, at most 6 levels, each up to 100 characters. Either one string with > between the levels ("Protein > Whey") or a list of levels (["Protein", "Whey"]). A deeper path makes the item invalid.
priority
number
The order you want this product listed in. Higher comes first; the default is 0. It decides order only — it never decides what the AI may say about a product.
attributes
object
Your own fields. See below.
search_terms
string[]
Extra words that should find this product — nicknames, an old name, a common misspelling. At most 100 words per product, its versions included. They are never shown to the AI and rules never match on them.
rule_keys
string[]
Keys this product carries. Up to 500, each up to 200 characters.
rules
string[]
Sentences the AI must follow about this product. Up to 100, each up to 5,000 characters.
variants
object[]
The versions of this product — flavors, sizes, colors. At most 500. See the next section.

The version object

Each entry in variants is one thing a customer can buy. Only id is required.

id
string or number required
The version's own stable identifier — usually your real SKU. It must be unique within the product; a repeat makes the whole product invalid.
options
object
What distinguishes this version, as name: value{ "Flavor": "Pistachio", "Size": "1 kg" }. At most 5 options, names up to 40 and values up to 60 characters. The order you send them in is the order we show them in, so send them the way you want them read.
price / sale_price / availability
string or number
This version's own price, sale price and stock status. Same rules as on the product.
link
string
A link straight to this version, if you have one. Falls back to the product's link.
image
string
This version's own image, if it differs.
attributes
object
Fields that belong to this version only — its own nutrition table, for instance.
priority
number
Order within the product. Higher comes first.
search_terms / rule_keys / rules
string[]
Exactly as on the product, but for this version. A version's keys count as the product's keys, so a rule keyed on something only one flavor contains still reaches the product.
If you send variants, the product's own price, sale_price and availability are ignored — the versions carry them. The run report counts this as "Product prices ignored (versions sent)", so a non-zero figure there means you are sending both and one of them is doing nothing.
A product with no variants is not a special case internally: it becomes a product with one unnamed version holding the price and availability you sent. That is why an older feed keeps working untouched.
There are no alternative field names.sku, url, image_link, salePrice and product_url are all ignored — use exactly the names above. Unknown keys are ignored silently.

attributes — your own fields

An object whose values are a string, number, boolean, an array of those, or one level of nested object — which is how you send something like a nutrition table:

"attributes": {
  "Serving": "1 scoop (32 g)",
  "Nutrition per serving": { "Energy": "128 kcal", "Protein": "24 g", "Fat": "2.2 g" }
}
String values are split on commas and semicolons."Pack size": "12,5 kg" becomes two values, 12 and 5 kg. Use an array for anything that legitimately contains a comma or semicolon.

Nesting deeper than one level makes the whole product invalid — not just that one field.

There is no length cap on attributes any more. They used to be cut at 4,000 characters per product; now everything you send is kept and indexed. You no longer have to order your fields by importance to protect them, and the report's old "Fields cut for length" counter no longer appears.

rule_keys and rules

Both are stored but never made searchable. They exist only for product rules.

  • rule_keys decide which rules apply to this product.
  • rules are sentences the AI receives verbatim, every time this product appears in an answer.
Because they are not searchable, sending an ingredient list only in rule_keys means the AI can never find the product by that ingredient. Put it in attributes as well if customers search for it.

What is not searchable

price, sale_price and availability are deliberately never part of what the AI learned — they change constantly and are read live from the versions when an answer is written. The AI cannot search on them.

search_terms are the mirror image: they are only searchable. They help the search find a product and are never shown to the AI, so a product's nicknames cannot leak into a sentence.

Invalid items

Invalid items are skipped, never fatal — the run continues and the report lists the first 50 with the reason. If the same id appears twice in one payload, the last one wins.

One version is not skipped on its own. If any version of a product fails validation, the whole product is skipped, so the catalogue never holds a product that is quietly missing half its sizes. The report counts these separately as "Versions that stopped a product".

Push — you send us changes

The endpoint

POST https://yaplet.com/api/products/push/{sourceId}

{sourceId} is the source's id — copy it from the Copy push URL button on the Sources tab.

Authentication

Send your organisation API key in the Y-API-Key header. This is the key from Settings → API, not the source's shared secret.

One organisation key grants push access to every Product API source in the organisation; the source id in the URL is what selects the target. Rotating the key breaks every shop integration in the organisation at once.

The body

FieldDefaultMeaning
mode"merge""merge" updates only what you send. "replace" makes this push a full snapshot.
products[]Product objects, exactly as above.
deleted_ids[]Products to remove. Ignored in replace mode.
const res = await fetch("https://yaplet.com/api/products/push/YOUR_SOURCE_ID", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "Y-API-Key": "YOUR_API_KEY",
  },
  body: JSON.stringify({
    mode: "merge",
    products: [
      {
        id: "SKU-1001",
        title: "Vitamin C 1000 mg, 60 tablets",
        link: "https://shop.example.com/products/vitamin-c-1000",
        price: "12.90 EUR",
        availability: "in stock",
        attributes: { Ingredients: ["ascorbic acid"] },
        rule_keys: ["ascorbic acid"],
      },
    ],
    deleted_ids: ["SKU-0999"],
  }),
});

const result = await res.json();
console.log(result.run_id, result.accepted);

What you get back

A successful push answers 202 Accepted:

{
  "run_id": "3f9d2c6e-1b7a-4c1e-9f0a-2d8e5b6c7a90",
  "status": "running",
  "received": 1,
  "accepted": 1,
  "invalid": 0,
  "report_url": "/api/products/push/YOUR_SOURCE_ID/runs/3f9d2c6e-1b7a-4c1e-9f0a-2d8e5b6c7a90"
}
A 202 does not mean the products landed. Your items have been validated, but the import itself runs in the background — limits, storage and indexing can still fail afterwards. Poll the run to be sure.

Checking a run

GET https://yaplet.com/api/products/push/{sourceId}/runs/{runId}

Same Y-API-Key header. Poll until status is no longer "running", then read the counters and, if status is "failed", the error and error_code fields.

Only the latest run is kept per source. Your next push — or a scheduled read that happens in between — overwrites it, and the old run_id then returns 404. Read a report before you send the next push. A failed run still answers 200; check the status field, not the HTTP code.

Push semantics

  • merge touches only the ids you send. Everything you leave out is untouched.
  • deleted_ids are product ids, not version ids. Deleting a product deletes its versions with it. An id you do not own, or that does not exist, is ignored silently. If an id is in both products and deleted_ids, the product wins.
  • mode: "replace" deletes every product of this source that is not in the payload.
A pushed product replaces all of its versions. There is no way to add or remove a single version on its own: send the product with the complete variants list you want it to end up with. A version you leave out of that list is deleted.
{"mode": "replace", "products": []}deletes every product of the source. Unlike a pull, there is no empty-payload guard on replace.

Products whose content has not changed cost nothing — they are not re-indexed. Changing only price, sale price or availability updates the row without re-indexing anything.

A successful push also brings back products that were hidden because the source had been failing. It does not restart a schedule that stopped after three failed reads — only Fetch now does that.

Errors

CodeWhen
400Malformed JSON, a bad mode, more than 20,000 items, or an empty merge
401Missing or invalid Y-API-Key
403Your organisation lacks the Content sources permission
404Unknown source, a source in another organisation, or a CSV source
409Another read or push is running for this source — wait and retry
411No Content-Length — chunked uploads are not accepted
413Body larger than 25 MB
429Too many requests. Retry-After tells you how long to wait

Rate limits

12 pushes per minute per source, and 60 per minute per organisation. Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset.

An organisation with no subscription and no credit balance gets smaller limits: 5 MB per body, 1,000 items per push, and 6 pushes a minute rather than 60. An over-size body is refused with 413 and an over-count push with 400. Adding credits or starting a subscription restores the full limits immediately.The dashboard's API tab always prints the numbers in force for your own organisation, and says when they are the free set — so check there rather than assuming the figures above.

Things that catch people out