priceq / API documentation
REST API · Version 1

priceq API

One key, two directions. Read everything priceq knows about your prices, and send your catalogue in from your own ERP or PIM.

Read

Products, retailer mappings, prices, alerts, trends and reports — into your own systems.

Deliver

Push your catalogue directly. No webshop connection, no CSV uploads.

OpenAPI file for Postman and code generators openapi-public.json · OpenAPI 3.1 · JSON

Base URL
https://app.priceq.io/api/v1
Step 1

Quickstart

Create a key in Settings → API keys, then make this call. If you get a list of your products back, you are connected — everything else in these docs is a variation on this.

Store the key in an environment variable. It is a password: anyone holding it can read your data and change your catalogue.

Your key is shown once

We store only a hash and cannot show it again. Lost it? Revoke it and create a new one. Only an administrator can create or revoke keys, and you can have at most 10 active at a time.

Your first request
curl "https://app.priceq.io/api/v1/skus?limit=3" \
  -H "Authorization: Bearer $PRICEQ_KEY" \
  -H "Accept-Language: en"
200 · response
{
  "items": [
    {
      "id": "67469b7c-0cd6-48b9-8753-89ddf9beb978",
      "sku": "FJ-FLASK-500",
      "ean": "8712345678906",
      "name": "Fjordlund vacuum flask 500 ml",
      "brand": "Fjordlund",
      "category": "drinkware",
      "status": "active",
      "source": "api",
      "sync_status": "active",
      "own_price_cents": 2495,
      "updated_at": "2026-09-10T06:33:13Z"
    }
  ],
  "total": 5
}
Step 2

Authentication

Send your key as a bearer token on every request. A key looks like pq_a1b2c3d4_<secret>; the part before the secret is the label you see in the key list, so you can tell two keys apart without us ever knowing the secret.

What a key can and cannot do

A key gives access to data, not to administration. It can read your products, mappings, prices and alerts, and it can deliver and update your catalogue.

It cannot change or cancel your subscription, pull the full data export, read the audit trail, manage team members or create more keys. That is deliberate — it makes "rotate that key" an ordinary sentence rather than a scary one.

Revoking is immediate

The next request with a revoked key gets a 401. Revoked keys stay in the list so "what was this and who made it" remains answerable.

Scope: how much a key may do

When you create a key you choose its scope. Give each integration no more than it needs.

ScopeMayFor
readRead only (GET)A dashboard, a report
ingestRead, plus deliver your catalogue (POST /catalog/…)Your ERP or PIM
fullEverything a key can do: also manage webhooks and webshop connections, handle alertsAn integration that does it all

Without a choice it is full. A key acting outside its scope gets a 403 with code: "key_scope" and the scope named, so you know which key to use instead.

Expiry and rotation

When you create a key you can give it an end date (expires_in_days, 1 to 365). On that day it stops working with the same 401 as a revoked key. Without one it works until you revoke it. To rotate: create a new key, put it in your system, revoke the old one. The list shows last_used_at to the minute, so an unused key is easy to spot.

Every request
Authorization: Bearer pq_a1b2c3d4_your-secret-part
A key that ends
POST /api-keys
{ "name": "ERP nightly", "scope": "ingest", "expires_in_days": 90 }
401 · missing or invalid key
{
  "detail": "Not signed in. Send your key as `Authorization: Bearer pq_...`."
}
Permission matrix
# allowed
GET    /skus  /mappings  /alerts  /trends/*
POST   /catalog/products  /catalog/sync
POST   /alerts/{id}/read  /alerts/scan
GET    /exports/report/{type}
*      /webhooks

# 403 — do this in the app instead
GET    /exports/full
GET    /audit-log
*      /api-keys  /team  /me/subscription
Step 3

Conventions

The API is REST-shaped: predictable resource URLs, JSON request and response bodies, standard HTTP verbs and status codes. Learn these six rules once and every endpoint behaves the way you expect.

RuleWhat it means
Money is integer cents1999 is € 19,99. Never a decimal — 12.34 and "12,34" are rejected. Sending money as a float costs someone a cent eventually.
Times are UTCISO-8601 with a Z, e.g. 2026-09-10T06:33:13Z.
Messages follow your languageAccept-Language: en, nl or de. Codes and field names stay English.
Lists page with limit/offsetEvery list returns items and total. limit=100&offset=200 gives you rows 201–300.
Errors carry detailEvery non-2xx body has a detail field — including 429. For a 422 from the framework it is an array naming each bad field.
Delivery is upsertYou key products on your own id. Sending the same product twice updates it; it never creates a duplicate.
Omitting a field keeps itFields you leave out are untouched. To clear one, send an empty string.

Pagination

Read the first page, then keep asking until you have total rows. There is no cursor: offsets are stable enough for a catalogue that changes a few times a day, and they let you resume from any point.

Language

Send Accept-Language: en (or nl, or de) and every human-readable message comes back in that language — errors, warnings, and the reason a sync was refused. Without the header you get Dutch, so an older integration keeps reading what it read yesterday.

Branch on codes, never on text

Field names, status values and HTTP status codes are stable English identifiers and never change with the language. The detail and warnings text is written for a human and will change — including when someone switches their language. Match on status and index, not on the sentence.

Page through every product
def all_products(session, key):
    offset, out = 0, []
    while True:
        r = session.get(
            "https://app.priceq.io/api/v1/skus",
            params={"limit": 1000, "offset": offset},
            headers={"Authorization": f"Bearer {key}"},
            timeout=60,
        )
        r.raise_for_status()
        page = r.json()
        out += page["items"]
        offset += 1000
        if offset >= page["total"]:
            return out
Read your data

Products

GET/skuslist, filter, page
GET/skus/{id}one product
GET/skus/{id}/reference-pricesyour prices per country
GET/skus/{id}/listingslinked retailer pages
DELETE/skus/{id}soft delete, 90-day bin

Query parameters

NameTypeDescription
statusstringactive, archived or deleted
categorystringCategory slug. Comma-separated for several (bottles,mugs); __none__ for uncategorised. Unknown slugs are ignored.
brandstringExact match, case-insensitive.
limitinteger1–1000, default 100.
offsetintegerDefault 0.

Product object

FieldTypeDescription
iduuidpriceq's id. Use it in the other product endpoints.
skustringYour article code.
eanstringEAN-13, or empty. This is what we match retailer pages on.
name, brand, mpnstringAs delivered.
categorystringCategory slug.
statusstringactive, archived, deleted.
sourcestringWhere it came from: manual, api, shopify, woocommerce, magento.
sync_statusstringactive, disabled, orphaned (gone from the source), archived.
own_price_centsintegerYour current selling price in cents.
updated_atdatetimeLast change, UTC.

/reference-prices returns one row per country with currency, msrp_cents, sell_price_cents, cost_cents, and the min_price_cents/max_price_cents band a price proposal must stay inside.

Filtered list
curl -G "https://app.priceq.io/api/v1/skus" \
  --data-urlencode "category=gpu,psu" \
  --data-urlencode "brand=Fjordlund" \
  --data-urlencode "limit=100" \
  -H "Authorization: Bearer $PRICEQ_KEY"
200 · /skus/{id}/reference-prices
[
  {
    "country": "NL",
    "currency": "EUR",
    "msrp_cents": 59999,
    "sell_price_cents": 2495,
    "cost_cents": 41000,
    "min_price_cents": 44900,
    "max_price_cents": null,
    "updated_at": "2026-09-10T06:33:13Z"
  }
]
Read your data

Retailer mappings

A mapping is the link between one of your products and one page at one retailer. It is what makes a price comparable at all, so it is usually the first thing to check when a number looks wrong.

GET/mappingslist and filter
NameTypeDescription
statestringmatched, in_review, unmatched, never_listed, dismissed
sku_iduuidOnly this product's mappings.
retailerstringRetailer slug, e.g. azerty.
limit / offsetinteger1–500, default 200.

Each mapping carries listing_url, state, confidence (0–100, or -1 when the EAN on the page contradicts yours), bucket (green/amber/red), last_observed_at and short sku and retailer objects.

Stale prices usually mean a broken mapping

If last_observed_at is days old, the retailer page probably moved. Filter on state=matched and sort by that field to find them.

200 · /mappings
{
  "items": [
    {
      "id": "3f0c…",
      "sku_id": "67469b7c…",
      "listing_url": "https://azerty.nl/p/…",
      "state": "matched",
      "confidence": 97,
      "bucket": "green",
      "flags": [],
      "last_observed_at": "2026-09-10T04:12:08Z",
      "retailer": { "slug": "azerty" }
    }
  ],
  "total": 1
}
Read your data

Alerts

GET/alertslist and filter
GET/alerts/summarycounts by type and severity
POST/alerts/scanrecalculate from known prices
POST/alerts/{id}/readmark as read
POST/alerts/{id}/snoozebody {"days": 7}
POST/alerts/{id}/resolveclose it
NameTypeDescription
statusstring[]unread, read, snoozed, ignored, muted, resolved
severitystring[]info, warning, critical
categorystringCategory slug.
sortstringrecent (default) or impact
limit / offsetinteger1–200, default 50.

Alert types

competitor_cheaperSomeone undercuts you.
structurally_more_expensiveYou have been above market for a while.
sudden_price_moveA sharp jump or drop.
new_retailerA shop started carrying your product.
retailer_dropped_productA shop stopped carrying it.
multi_retailer_oosOut of stock at several shops at once.
own_price_inconsistentYour own prices disagree between channels.

POST /alerts/scan recalculates from the prices we already collected. It fetches nothing from retailers and costs nothing, so it is safe to call right after a delivery.

Unread, most impactful first
curl "https://app.priceq.io/api/v1/alerts?status=unread&sort=impact" \
  -H "Authorization: Bearer $PRICEQ_KEY"
200 · /alerts
{
  "items": [
    {
      "id": "9c2b…",
      "alert_type": "competitor_cheaper",
      "severity": "warning",
      "status": "unread",
      "title": "Azerty is 4% goedkoper",
      "sku_id": "67469b7c…",
      "impact_score": 82.4,
      "created_at": "2026-09-10T05:02:00Z"
    }
  ],
  "total": 1,
  "unread_count": 1
}
Read your data

Trends & reports

GET/pricing/suggestionscurrent price proposals
GET/trends/portfolioyour position over time
GET/trends/top-moversbiggest changes
GET/trends/sku/{id}one product's history
GET/exports/report/{type}Excel workbook

Three report types: prijspositie (price position), adviesprijs (MSRP compliance) and categorie (category development). Pass from and to as ISO-8601 timestamps. The response is an .xlsx file, not JSON.

Column headings and the filename follow Accept-Language; without that header you get Dutch.

Price proposals need the right plan

/pricing/suggestions returns 402 on the Start plan. The body names the limit and your plan so your code can tell "not allowed" from "plan too small".

Download a report
curl -L "https://app.priceq.io/api/v1/exports/report/prijspositie" \
  -G --data-urlencode "from=2026-08-01T00:00:00Z" \
     --data-urlencode "to=2026-09-01T00:00:00Z" \
  -H "Authorization: Bearer $PRICEQ_KEY" \
  -H "Accept-Language: en" \
  -o price-position.xlsx
Read your data

Plan & usage

GET/me/entitlementslimits, usage, features

Call this before a large delivery to see how much headroom you have. limits.skus is the ceiling; usage.skus is what you are using now. Products beyond the ceiling come back as skipped rather than being rejected.

200 · /me/entitlements
{
  "plan": "start",
  "limits":   { "skus": 200, "retailers": 3, "users": 2 },
  "usage":    { "skus": 64,  "retailers": 0, "users": 0 },
  "features": { "price_proposals": false },
  "scans_per_day": 1,
  "trial_ends_at": null
}
Send your catalogue

Two ways to deliver

No webshop to connect, but an ERP or a PIM? Push your products in. There are two endpoints and the difference is what they are allowed to do.

Partial deliveryFull sync
EndpointPOST /catalog/productsPOST /catalog/sync → pages → /complete
You sendSome products: 3, or 300Your whole catalogue, in pages
It doesCreate or updateCreate, update and retire what you no longer have
Removes anything?NeverYes, on completion
Use it forPrice changes, new products, hourly deltasNightly full sync, first load

Both take at most 500 products per request and share the same product shape and the same response shape.

A connected webshop wins

If you have Shopify, WooCommerce or Magento connected, that shop owns your catalogue and delivery returns 409. Disconnect it to deliver yourself instead.

POST /catalog/products
curl -X POST "https://app.priceq.io/api/v1/catalog/products" \
  -H "Authorization: Bearer $PRICEQ_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "currency": "EUR",
    "products": [
      {
        "id": "ERP-1",
        "name": "Fjordlund vacuum flask 500 ml",
        "ean": "8712345678906",
        "sku": "FJ-FLASK-500",
        "brand": "Fjordlund",
        "category": "Drinkware",
        "price": 2495,
        "list_price": 59999,
        "cost_price": 41000
      }
    ]
  }'
Send your catalogue

Product fields

FieldTypeDescription
idstringreqThe id in your system. We key on this. Max 255 chars.
namestringreqProduct name. Max 255 chars.
skustringoptArticle code, must be unique. Left out, it becomes api-<id>.
eanstringoptEAN-13, UPC-12 or GTIN-14, as a string.
mpnstringoptManufacturer part number. Left out, we use the article code.
brandstringoptBrand name.
categorystringoptCategory name. We create it if it does not exist yet.
parent_idstringoptParent product's id, for variants.
priceintegeroptSelling price in cents: 1999 = € 19,99.
list_priceintegeroptMSRP or was-price, in cents.
cost_priceintegeroptCost in cents. We use it for your margin floor.
statusstringoptactive (default) or disabled.

At request level: currency (three letters, default EUR) applies to every price in that request. One currency per request — the price table holds one currency per country, so two in one delivery would overwrite each other.

id is the field that matters

It is the id the product has in your system, and it is what we key on. Sending the same id again updates the product and never creates a duplicate. If that id changes on your side, it is a different product to us. Surrounding spaces are ignored, so " ERP-1 " and "ERP-1" are the same product.

Leaving a field out keeps it

Send only id, name and price and the product keeps the EAN, brand and category it already had. That is deliberate: your EAN is how we link retailer pages to your product, and a price update has no business throwing it away.

To actually clear a field, send it as an empty string: "brand": "". Omitting is not clearing. A price cannot be cleared — "price": null leaves the price as it was.

Unknown fields are rejected

A field we do not recognise — "pirce": 999 — fails that product with 'pirce' is not a known field -- a typo?. Silently ignoring it would mean everything looks fine while your prices never arrive.

Omitting vs. clearing
// first delivery
{ "id": "ERP-1", "name": "Vacuum flask 500 ml",
  "ean": "8712345678906", "brand": "Fjordlund" }

// price-only update — ean and brand survive
{ "id": "ERP-1", "name": "Vacuum flask 500 ml", "price": 2295 }
// → ean 8712345678906, brand Fjordlund

// explicit clear
{ "id": "ERP-1", "name": "Vacuum flask 500 ml", "brand": "" }
// → brand removed
Prices, accepted and rejected
"price": 1999      // ✓ € 19,99
"price": "1999"    // ✓ digits in a string
"price": null      // ✓ leaves it unchanged

"price": 12.34     // ✗ no decimals
"price": "12,34"   // ✗ no comma
"price": -5        // ✗ not negative
Send your catalogue

The response

Every product is judged on its own. One bad product in a batch of 500 leaves 499 processed and one failed row — it does not reject the request.

StatusMeaning
createdNew product.
updatedAlready existed with this id and was updated.
failedNot stored. detail says why.
skippedNot stored because your plan is full. detail names your limit.

warnings are things that did go through but deserve a look. summary.warned counts how many products have one, so you do not have to walk every row to know whether there is anything to see.

The HTTP status tells you whether to look at all:

200Everything processed.
207Partly — read results.
400Nothing processed; every product failed.

Common failure messages

price format'price' must be a whole number of cents (1999 = € 19.99), with no comma or dot
unknown field'pirce' is not a known field -- a typo?
missing name'name' is missing
too long'name' is too long (at most 255 characters)
bad status'status' must be 'active' or 'disabled'
duplicate idThis id appears more than once in this request; only the first was processed.
plan fullYour plan allows 200 products; this one no longer fitted.
207 · partial success
{
  "summary": {
    "received": 3, "created": 1, "updated": 1,
    "failed": 1, "skipped": 0, "warned": 1
  },
  "results": [
    {
      "index": 0,
      "id": "ERP-1",
      "status": "created",
      "detail": null,
      "warnings": [
        "EAN 8712345678906 is already on 'ERP-7' in your catalogue…"
      ]
    },
    {
      "index": 1, "id": "ERP-2",
      "status": "updated",
      "detail": null, "warnings": []
    },
    {
      "index": 2, "id": "ERP-3",
      "status": "failed",
      "detail": "'price' must be a whole number of cents…",
      "warnings": []
    }
  ],
  "sync_id": null
}
Handling it
# results[i].index matches products[i] — always
sent = payload["products"]
for r in body["results"]:
    original = sent[r["index"]]
    if r["status"] == "failed":
        log.error("rejected %s: %s", original["id"], r["detail"])
Send your catalogue

EAN handling

The EAN is how we link a retailer page to your product, so we are strict with it: we repair what we can and refuse what we cannot.

You sendWe store
"871 2345-678906"8712345678906 — spaces and dashes stripped
"036000291452" (UPC-12)0036000291452 — leading zero added
"04006381333931" (GTIN-14)4006381333931 — packaging digit dropped
"8712345678900" (bad check digit)nothing — product stored, EAN not, and you get a warning
20 digits, or 8 digitsnothing — same, with a warning

If a valid EAN was already on the product, it stays. The warning says so.

Send EANs as strings

"ean": 8712345678906 is rejected — JSON numbers lose leading zeros, which silently turns a valid UPC into a wrong EAN. Always quote it.

A wrong EAN links to the wrong retailer page, and you only notice from the prices. That is why no EAN beats a wrong one.

Warning on an invalid EAN
{
  "index": 0,
  "id": "ERP-9",
  "status": "updated",
  "detail": null,
  "warnings": [
    "EAN '8712345678900' is not a valid EAN-13, UPC-12 or"
    " GTIN-14 (wrong check digit or length) and was not stored."
    " The EAN already on the product stays."
  ]
}
Send your catalogue

Duplicates

Same EAN on two products

Stored, with a warning on both. Colour variants and bundles that share a barcode do exist, so we do not refuse — but it has a consequence you need to know: we link retailer pages on EAN, so both products will receive each other's prices. The warning says exactly that.

This works within one request and against products already in your catalogue, including ones that arrived by CSV or from a webshop.

Same article code on two products

Not possible — an article code has to be unique. The second one gets a suffix (ART-1-T-2) and a warning naming the new code.

Same id twice in one request

That is a bug in your export. The first wins; the second comes back as failed with an explanation, instead of silently overwriting.

Two products, one EAN
"warnings": [
  "EAN 8712345678906 is also on 'E-2' in this request."
  " We match retailer pages on EAN, so these products will"
  " receive each other's prices."
]
Article code collision
"warnings": [
  "Article code 'ART-1' is also on 'T-1' in this request."
  " An article code has to be unique, so this product"
  " becomes 'ART-1-T-2'."
]
Send your catalogue

Full catalogue sync

/catalog/products has no idea what else you have, so it never removes anything. To let us see what you no longer sell, open a sync, deliver in pages, and complete it.

POST/catalog/syncopen → sync_id
POST/catalog/sync/{id}/productsone page of ≤500
POST/catalog/sync/{id}/completefinish and retire
GET/catalog/sync/{id}status

On completion, everything you did not deliver this round counts as gone. Those products move to sync_status: orphaned — they are not deleted, and their price history stays. Deliver them again later and they come back to life.

Two brakes on removal

Both exist because a delivery that broke halfway must never empty a catalogue.

1. A sync with no delivered products retires nothing.
2. A sync that would retire more than half your products does nothing at all and tells you why (state: aborted, with detail). Check your export and complete again.

There is at most one open sync at a time; a second POST /catalog/sync returns the running one. A sync left open for 24 hours expires, and the next open gives you a fresh one — otherwise last week's waterline would still be deciding what counts as gone.

20,000 products

Forty pages of 500. One page takes roughly one and a half seconds, so a full catalogue lands in about a minute. The rate limit of 60 requests per minute is not in the way.

What does apply is your plan ceiling. Products above it are not thrown away; they come back as skipped with your limit in detail. They do not count as "delivered" for the retirement step, so if that would push the sync over the half-the-catalogue brake, the brake grabs.

Full sync, end to end
import os, requests

API = "https://app.priceq.io/api/v1"
H = {"Authorization": f"Bearer {os.environ['PRICEQ_KEY']}"}
s = requests.Session()

# 1 — open
sync = s.post(f"{API}/catalog/sync", headers=H, timeout=30).json()
sid = sync["sync_id"]

# 2 — deliver in pages of 500
for i in range(0, len(catalogue), 500):
    page = catalogue[i:i + 500]
    r = s.post(
        f"{API}/catalog/sync/{sid}/products",
        json={"currency": "EUR", "products": page},
        headers={**H, "Idempotency-Key": f"{sid}-{i}"},
        timeout=120,
    )
    for item in r.json()["results"]:
        if item["status"] == "failed":
            print("rejected", item["id"], item["detail"])

# 3 — complete; this is the step that retires
done = s.post(f"{API}/catalog/sync/{sid}/complete", headers=H, timeout=60).json()
if done["state"] == "aborted":
    raise SystemExit(f"sync refused: {done['detail']}")
print(f"retired {done['orphaned']} products")
200 · sync completed
{
  "sync_id": "d76839a6-bdf9-40bd-b133-c0903eb18f4e",
  "state": "completed",
  "started_at": "2026-09-10T02:00:00Z",
  "received": 19842,
  "created": 12,
  "updated": 19830,
  "failed": 0,
  "skipped": 0,
  "orphaned": 158,
  "detail": null,
  "completed_at": "2026-09-10T02:01:07Z"
}
200 · sync refused by the brake
{
  "state": "aborted",
  "orphaned": 0,
  "detail": "This sync would mark 4021 of your 5000 products as"
            " gone. That is too many to do unchecked, so nothing"
            " was changed."
}
Send your catalogue

Retrying safely

Retrying is safe by construction. We key on your id, so sending the same products again updates them and creates nothing extra. You do not have to do anything for that.

What an Idempotency-Key adds is the response. If your connection dropped and you do not know whether the request landed, a retry with the same key returns the answer we gave the first time — so you see "12 created" rather than "0 created, 12 updated", which would make your own bookkeeping wrong.

We keep that answer for 24 hours. Reusing a key with different content returns 409: something is wrong on your side and we would rather not hide it.

Pick keys that describe the work

{sync_id}-{page_offset} or nightly-2026-09-10-page-3. Random UUIDs work too, but then a retry after a crash cannot find the previous answer.

With an idempotency key
curl -X POST "$API/catalog/products" \
  -H "Authorization: Bearer $PRICEQ_KEY" \
  -H "Idempotency-Key: nightly-2026-09-10-page-3" \
  -H "Content-Type: application/json" \
  --data @page-003.json
409 · key reused with other content
{
  "detail": "This Idempotency-Key was already used for a request"
            " with different content. Use a new key."
}
Reference

Recipes

Two integrations cover almost every case. Run them both and you have a catalogue that stays correct without anyone touching it.

Nightly full sync

Once a day, send everything. This is the only way we can see that a product disappeared from your assortment. Schedule it outside business hours; a 20,000-product catalogue takes about a minute.

Check state on the completion response. If it comes back aborted, your export was probably incomplete — do not retry blindly, look at it.

Hourly price delta

Between full syncs, push only what changed. Use /catalog/products: it never removes anything, so a partial delivery is harmless. Send only id and price — every other field stays as it was.

Reacting to alerts

Rather than polling, subscribe a webhook to alert.triggered. If you would still rather poll, GET /alerts?status=unread&sort=impact once every few minutes is plenty; alerts are recalculated after each scrape, not continuously.

Hourly price delta
import os, itertools, requests

API = "https://app.priceq.io/api/v1"
H = {"Authorization": f"Bearer {os.environ['PRICEQ_KEY']}"}

changed = [
    {"id": p.code, "name": p.name, "price": p.cents}
    for p in erp.products_changed_since(last_run)
]

def chunks(seq, n=500):
    it = iter(seq)
    while (batch := list(itertools.islice(it, n))):
        yield batch

for i, batch in enumerate(chunks(changed)):
    r = requests.post(
        f"{API}/catalog/products",
        json={"currency": "EUR", "products": batch},
        headers={**H, "Idempotency-Key": f"delta-{run_id}-{i}"},
        timeout=120,
    )
    if r.status_code == 429:
        time.sleep(int(r.headers["Retry-After"]))
        continue
    for item in r.json()["results"]:
        if item["warnings"]:
            log.warning("%s: %s", item["id"], item["warnings"])
Reference

Errors

Every non-2xx body carries detail. Branch on the status code, not on the text.

CodeMeaningWhat to do
207Some products processed, some notRead results row by row
400None of your products processedSame — every row says why
401Key unknown, revoked or mis-pastedCheck the key; create a new one if needed
402Your plan does not allow thisThe body names the limit, current usage and plan
403This is administration, not data; or the key's scope is too narrow (code: "key_scope")Do it in the app, or use a key with a wider scope
404Does not exist, or is not yoursBoth give the same answer, on purpose
409A webshop owns your catalogue; or a reused Idempotency-Key; or that sync is already closedThe body says which of the three
413The request body is over 2 MBSend fewer products per request; 500 fit comfortably
422The request itself is malformed: empty list, over 500 products, bad currency, invalid JSONdetail is an array naming each field (loc), what is wrong (msg) and the kind (type). Your input is not echoed back
429Too many requestsWait Retry-After seconds
500Our faultRetry; if it persists, send us the X-Request-Id
Every response carries X-Request-Id

Log it. When something goes wrong we can trace exactly that request, which turns "it failed yesterday" into a two-minute answer.

422 · malformed request
{
  "detail": [
    {
      "type": "too_long",
      "loc": ["body", "products"],
      "msg": "List should have at most 500 items"
    }
  ]
}
402 · plan limit reached
{
  "detail": {
    "code": "plan_limit",
    "resource": "skus",
    "limit": 200,
    "current": 200,
    "plan": "start"
  }
}
Reference

Rate limits

Delivery endpoints allow 60 requests per minute, counted per organisation rather than per key. At 500 products per request that is 30,000 products a minute — the limit exists to stop a runaway script, not to slow down a real integration.

Every response to a delivery endpoint carries the current state, so you can slow down before you hit the wall rather than after.

HeaderMeaning
X-RateLimit-LimitRequests allowed in the window (60).
X-RateLimit-RemainingHow many you have left.
X-RateLimit-ResetUnix timestamp when the window resets.
Retry-AfterSeconds to wait. Present on the 429; honour it.

A few actions have their own, stricter limit, because each one makes our servers do something on your behalf: opening or completing a full sync 10 per hour, creating a webhook endpoint 20 per hour, rotating a webhook secret 10 per hour, connecting a webshop 10 per hour.

Read endpoints are not rate-limited today. That may change, so handle a 429 everywhere.

429 · with headers
HTTP/1.1 429 Too Many Requests
x-ratelimit-limit: 60
x-ratelimit-remaining: 0
x-ratelimit-reset: 1789048083
retry-after: 45

{
  "detail": "Too many requests (60 per 1 minute). The Retry-After"
            " header says how many seconds to wait."
}
Backing off politely
def post_with_backoff(session, url, **kw):
    for attempt in range(5):
        r = session.post(url, **kw)
        if r.status_code != 429:
            return r
        time.sleep(int(r.headers.get("Retry-After", 60)))
    r.raise_for_status()
Reference

Webhooks

Rather than asking, be told. Set an endpoint in Settings → Webhooks or through the API, and pick the events you care about. Every delivery is HMAC-signed; the delivery log lives in that same screen.

EventFires when
alert.triggeredA new alert appears.
sku.addedA product enters your catalogue.
sku.archivedA product is archived.
msrp.changedAn MSRP changes.
run.completedA price scan finishes.
catalog.sync.completedA full sync finishes. Payload is the sync object, including orphaned.
GET/webhookslist endpoints
POST/webhookscreate one
PATCH/webhooks/{id}pause or edit
POST/webhooks/{id}/rotate-secretnew secret, shown once
DELETE/webhooks/{id}remove
GET/webhooks/deliveriesdelivery log

The address

https:// only, on a public address of your own server. An address that points into a private network is refused with a 422. Redirects are not followed: a 301 or 302 counts as a failed delivery. Reply 2xx quickly and do the work afterwards — we read your status code and nothing else.

Verifying a delivery

You get a secret (whsec_…) once, when you create the endpoint. Every delivery carries two signatures; you need only one. The Standard Webhooks headers are the ones to use, because they carry a timestamp — without one, an intercepted delivery could be replayed against you forever.

HeaderContents
webhook-idThe delivery. The same id on a retry, so deduplicate on it.
webhook-timestampUnix seconds of this attempt.
webhook-signaturev1,<base64> — HMAC-SHA256 with your secret over "{id}.{timestamp}.{body}". During a rotation there are two, space-separated.
X-priceq-Signaturesha256=<hex> — HMAC-SHA256 over the body alone. The original form; still sent.

Any Standard Webhooks verifier library works as is. Reject anything older than five minutes, and compare in constant time.

Rotating the secret

POST /webhooks/{id}/rotate-secret returns a new secret, once. For 24 hours we sign with both the old and the new, so you can switch your verifier over without missing a delivery.

When your server keeps failing

A 5xx, 408, 429 or a timeout is retried: after 10 s, 1 min, 5 min and 15 min, five attempts in all. Any other 4xx is not — that is something on your side. After ten consecutive failed deliveries the endpoint is switched off; it then shows active: false with disabled_reason: "auto:consecutive_failures". Turn it back on with PATCH … {"active": true} once fixed.

Subscribe to sync completions
curl -X POST "$API/webhooks" \
  -H "Authorization: Bearer $PRICEQ_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://erp.example.com/hooks/priceq",
    "description": "Nightly sync result",
    "event_types": ["catalog.sync.completed"]
  }'
Verify (Python)
import base64, hmac, hashlib, time

def verify(secret, headers, body, tolerance=300):
    ts = headers["webhook-timestamp"]
    if abs(time.time() - int(ts)) > tolerance:
        return False   # too old: a replay
    signed = f"{headers['webhook-id']}.{ts}.".encode() + body
    mine = base64.b64encode(
        hmac.new(secret.encode(), signed, hashlib.sha256).digest()
    ).decode()
    theirs = [s.removeprefix("v1,")
              for s in headers["webhook-signature"].split(" ")]
    return any(hmac.compare_digest(mine, t) for t in theirs)
Delivery headers
webhook-id: 3f2a…
webhook-timestamp: 1789048083
webhook-signature: v1,K2h…   (two during a rotation)
X-priceq-Signature: sha256=9c4e…
X-priceq-Event: catalog.sync.completed
X-priceq-Delivery-Id: 3f2a…
Reference

Audit trail

Every delivery and every completed or aborted sync lands in Settings → Activity log, with the counts and which key did it. Your colleague can see what the integration did last night without opening the integration's own logs.

ActionRecorded when
catalog.api_deliveredEvery delivery, with the full summary.
catalog.api_sync_completedA sync finished; includes how many were retired.
catalog.api_sync_abortedA sync was refused; includes why.

One row per delivery, not per product — 20,000 rows say less than one row with the counts. The per-product outcome is already in the response you received.

The activity log itself is administration, so it is readable in the app and not with a key.

Activity log entry
catalog.api_delivered   ·  api_key  ·  06:31:56
  received 500 · created 12 · updated 488
  failed 0 · skipped 0 · warned 3

catalog.api_sync_completed  ·  api_key  ·  02:01:07
  received 19842 · orphaned 158
Reference

Versioning

The version sits in the path: /api/v1. Within v1 we only make additive changes — new fields on a response, new optional fields on a request, new endpoints. Write your integration so unknown response fields are ignored and it will keep working.

Anything that would break an existing integration gets a new version path, and v1 keeps running alongside it. We will not silently change what a field means.

What counts as breaking, and how much notice you get

Breaking: removing or renaming a field, changing a type, adding a required parameter, changing a status code. That only happens under a new path (/v2) and with at least six months' notice: an entry in the changelog below and an e-mail to the administrators of every organisation with an active key. During that period v1 responses carry Deprecation and Sunset headers with the date.

Base URL, status, support

Base URLhttps://app.priceq.io/api/v1 — one environment; there is no sandbox. Test with a read-scoped key, or a separate organisation.
OpenAPIThe customer-facing schema is available as a downloadable file (openapi-public.json) for Postman and code generators. The live /openapi.json is not exposed.
StatusIncidents and uptime on our public status page (link on the marketing site).
Support[email protected], answered within one working day.
SecuritySuspect a leaked key or webhook secret? Revoke the key or rotate the secret — both take effect at once. Report a security issue in priceq itself to the same address. The same details, in the form security researchers look for, are in /.well-known/security.txt.

Changelog

DateChange
2026-09-10Keys have a scope (read / ingest / full) and an optional end date. Webhooks: https only, Standard Webhooks headers (webhook-id, webhook-timestamp, webhook-signature) next to the existing signature, secret rotation, automatic switch-off after ten failures. Requests over 2 MB get 413. A 422 no longer echoes your input.
2026-09-09Catalogue delivery: /catalog/products and the sync session. Per-product validation and warnings. catalog.sync.completed webhook. Rate-limit headers and Retry-After on 429. Messages now follow Accept-Language (nl / en / de).
2026-08-12Customer API keys. Reports as Excel workbooks.
Forward-compatible parsing
# good — ignores fields it does not know
price = item["own_price_cents"]

# fragile — breaks when we add a field
id_, sku, ean, name = item.values()

priceq · part of Outflanq · API v1 · last updated 10 September 2026 · questions: [email protected]