priceq API
One key, two directions. Read everything priceq knows about your prices, and send your catalogue in from your own ERP or PIM.
Products, retailer mappings, prices, alerts, trends and reports — into your own systems.
Push your catalogue directly. No webshop connection, no CSV uploads.
OpenAPI file for Postman and code generators openapi-public.json · OpenAPI 3.1 · JSON
https://app.priceq.io/api/v1
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.
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.
curl "https://app.priceq.io/api/v1/skus?limit=3" \
-H "Authorization: Bearer $PRICEQ_KEY" \
-H "Accept-Language: en"
import os, requests
r = requests.get(
"https://app.priceq.io/api/v1/skus",
params={"limit": 3},
headers={"Authorization": f"Bearer {os.environ['PRICEQ_KEY']}"},
timeout=30,
)
r.raise_for_status()
print(r.json()["total"], "products")
const res = await fetch(
"https://app.priceq.io/api/v1/skus?limit=3",
{ headers: { Authorization: `Bearer ${process.env.PRICEQ_KEY}` } }
);
if (!res.ok) throw new Error(`priceq ${res.status}`);
const { items, total } = await res.json();
console.log(total, "products");
# PowerShell has no real curl — use Invoke-RestMethod
$h = @{ Authorization = "Bearer $env:PRICEQ_KEY" }
$r = Invoke-RestMethod "https://app.priceq.io/api/v1/skus?limit=3" -Headers $h
$r.items | Format-Table sku, name, own_price_cents
{
"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
}
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.
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.
| Scope | May | For |
|---|---|---|
read | Read only (GET) | A dashboard, a report |
ingest | Read, plus deliver your catalogue (POST /catalog/…) | Your ERP or PIM |
full | Everything a key can do: also manage webhooks and webshop connections, handle alerts | An 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.
Authorization: Bearer pq_a1b2c3d4_your-secret-part
POST /api-keys
{ "name": "ERP nightly", "scope": "ingest", "expires_in_days": 90 }
{
"detail": "Not signed in. Send your key as `Authorization: Bearer pq_...`."
}
# 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
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.
| Rule | What it means |
|---|---|
| Money is integer cents | 1999 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 UTC | ISO-8601 with a Z, e.g. 2026-09-10T06:33:13Z. |
| Messages follow your language | Accept-Language: en, nl or de. Codes and field names stay English. |
| Lists page with limit/offset | Every list returns items and total. limit=100&offset=200 gives you rows 201–300. |
Errors carry detail | Every 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 upsert | You key products on your own id. Sending the same product twice updates it; it never creates a duplicate. |
| Omitting a field keeps it | Fields 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.
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.
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
async function allProducts(key) {
const out = [];
let offset = 0, total = Infinity;
while (offset < total) {
const res = await fetch(
`https://app.priceq.io/api/v1/skus?limit=1000&offset=${offset}`,
{ headers: { Authorization: `Bearer ${key}` } }
);
if (!res.ok) throw new Error(`priceq ${res.status}`);
const page = await res.json();
out.push(...page.items);
total = page.total;
offset += 1000;
}
return out;
}
Products
Query parameters
| Name | Type | Description |
|---|---|---|
status | string | active, archived or deleted |
category | string | Category slug. Comma-separated for several (bottles,mugs); __none__ for uncategorised. Unknown slugs are ignored. |
brand | string | Exact match, case-insensitive. |
limit | integer | 1–1000, default 100. |
offset | integer | Default 0. |
Product object
| Field | Type | Description |
|---|---|---|
id | uuid | priceq's id. Use it in the other product endpoints. |
sku | string | Your article code. |
ean | string | EAN-13, or empty. This is what we match retailer pages on. |
name, brand, mpn | string | As delivered. |
category | string | Category slug. |
status | string | active, archived, deleted. |
source | string | Where it came from: manual, api, shopify, woocommerce, magento. |
sync_status | string | active, disabled, orphaned (gone from the source), archived. |
own_price_cents | integer | Your current selling price in cents. |
updated_at | datetime | Last 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.
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"
[
{
"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"
}
]
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.
| Name | Type | Description |
|---|---|---|
state | string | matched, in_review, unmatched, never_listed, dismissed |
sku_id | uuid | Only this product's mappings. |
retailer | string | Retailer slug, e.g. azerty. |
limit / offset | integer | 1–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.
If last_observed_at is days old, the retailer page probably moved. Filter on state=matched and sort by that field to find them.
{
"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
}
Alerts
{"days": 7}| Name | Type | Description |
|---|---|---|
status | string[] | unread, read, snoozed, ignored, muted, resolved |
severity | string[] | info, warning, critical |
category | string | Category slug. |
sort | string | recent (default) or impact |
limit / offset | integer | 1–200, default 50. |
Alert types
competitor_cheaper | Someone undercuts you. |
structurally_more_expensive | You have been above market for a while. |
sudden_price_move | A sharp jump or drop. |
new_retailer | A shop started carrying your product. |
retailer_dropped_product | A shop stopped carrying it. |
multi_retailer_oos | Out of stock at several shops at once. |
own_price_inconsistent | Your 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.
curl "https://app.priceq.io/api/v1/alerts?status=unread&sort=impact" \
-H "Authorization: Bearer $PRICEQ_KEY"
{
"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
}
Trends & reports
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.
/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".
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
Plan & usage
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.
{
"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
}
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 delivery | Full sync | |
|---|---|---|
| Endpoint | POST /catalog/products | POST /catalog/sync → pages → /complete |
| You send | Some products: 3, or 300 | Your whole catalogue, in pages |
| It does | Create or update | Create, update and retire what you no longer have |
| Removes anything? | Never | Yes, on completion |
| Use it for | Price changes, new products, hourly deltas | Nightly full sync, first load |
Both take at most 500 products per request and share the same product shape and the same response shape.
If you have Shopify, WooCommerce or Magento connected, that shop owns your catalogue and delivery returns 409. Disconnect it to deliver yourself instead.
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
}
]
}'
import os, requests
payload = {
"currency": "EUR",
"products": [{
"id": "ERP-1",
"name": "Fjordlund vacuum flask 500 ml",
"ean": "8712345678906",
"price": 2495,
}],
}
r = requests.post(
"https://app.priceq.io/api/v1/catalog/products",
json=payload,
headers={"Authorization": f"Bearer {os.environ['PRICEQ_KEY']}"},
timeout=120,
)
body = r.json()
for item in body["results"]:
if item["status"] == "failed":
print(item["id"], item["detail"])
for w in item["warnings"]:
print("warn", item["id"], w)
const res = await fetch(
"https://app.priceq.io/api/v1/catalog/products",
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.PRICEQ_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
currency: "EUR",
products: [{ id: "ERP-1", name: "Vacuum flask 500 ml", price: 2495 }],
}),
}
);
const body = await res.json();
body.results
.filter((r) => r.status === "failed")
.forEach((r) => console.error(r.id, r.detail));
Product fields
| Field | Type | Description | |
|---|---|---|---|
id | string | req | The id in your system. We key on this. Max 255 chars. |
name | string | req | Product name. Max 255 chars. |
sku | string | opt | Article code, must be unique. Left out, it becomes api-<id>. |
ean | string | opt | EAN-13, UPC-12 or GTIN-14, as a string. |
mpn | string | opt | Manufacturer part number. Left out, we use the article code. |
brand | string | opt | Brand name. |
category | string | opt | Category name. We create it if it does not exist yet. |
parent_id | string | opt | Parent product's id, for variants. |
price | integer | opt | Selling price in cents: 1999 = € 19,99. |
list_price | integer | opt | MSRP or was-price, in cents. |
cost_price | integer | opt | Cost in cents. We use it for your margin floor. |
status | string | opt | active (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.
// 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
"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
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.
| Status | Meaning |
|---|---|
| created | New product. |
| updated | Already existed with this id and was updated. |
| failed | Not stored. detail says why. |
| skipped | Not 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:
| 200 | Everything processed. |
| 207 | Partly — read results. |
| 400 | Nothing 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 id | This id appears more than once in this request; only the first was processed. |
| plan full | Your plan allows 200 products; this one no longer fitted. |
{
"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
}
# 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"])
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 send | We 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 digits | nothing — same, with a warning |
If a valid EAN was already on the product, it stays. The warning says so.
"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.
{
"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."
]
}
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.
"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."
]
"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'."
]
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.
sync_idOn 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.
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.
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")
# 1 — open
SID=$(curl -s -X POST "$API/catalog/sync" \
-H "Authorization: Bearer $PRICEQ_KEY" \
| sed -n 's/.*"sync_id":"\([^"]*\)".*/\1/p')
# 2 — one page (repeat for each chunk of 500)
curl -s -X POST "$API/catalog/sync/$SID/products" \
-H "Authorization: Bearer $PRICEQ_KEY" \
-H "Content-Type: application/json" \
--data @page-001.json
# 3 — complete
curl -s -X POST "$API/catalog/sync/$SID/complete" \
-H "Authorization: Bearer $PRICEQ_KEY"
{
"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"
}
{
"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."
}
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.
{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.
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
{
"detail": "This Idempotency-Key was already used for a request"
" with different content. Use a new key."
}
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.
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"])
Errors
Every non-2xx body carries detail. Branch on the status code, not on the text.
| Code | Meaning | What to do |
|---|---|---|
| 207 | Some products processed, some not | Read results row by row |
| 400 | None of your products processed | Same — every row says why |
| 401 | Key unknown, revoked or mis-pasted | Check the key; create a new one if needed |
| 402 | Your plan does not allow this | The body names the limit, current usage and plan |
| 403 | This 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 |
| 404 | Does not exist, or is not yours | Both give the same answer, on purpose |
| 409 | A webshop owns your catalogue; or a reused Idempotency-Key; or that sync is already closed | The body says which of the three |
| 413 | The request body is over 2 MB | Send fewer products per request; 500 fit comfortably |
| 422 | The request itself is malformed: empty list, over 500 products, bad currency, invalid JSON | detail is an array naming each field (loc), what is wrong (msg) and the kind (type). Your input is not echoed back |
| 429 | Too many requests | Wait Retry-After seconds |
| 500 | Our fault | Retry; if it persists, send us the X-Request-Id |
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.
{
"detail": [
{
"type": "too_long",
"loc": ["body", "products"],
"msg": "List should have at most 500 items"
}
]
}
{
"detail": {
"code": "plan_limit",
"resource": "skus",
"limit": 200,
"current": 200,
"plan": "start"
}
}
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.
| Header | Meaning |
|---|---|
X-RateLimit-Limit | Requests allowed in the window (60). |
X-RateLimit-Remaining | How many you have left. |
X-RateLimit-Reset | Unix timestamp when the window resets. |
Retry-After | Seconds 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.
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."
}
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()
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.
| Event | Fires when |
|---|---|
alert.triggered | A new alert appears. |
sku.added | A product enters your catalogue. |
sku.archived | A product is archived. |
msrp.changed | An MSRP changes. |
run.completed | A price scan finishes. |
catalog.sync.completed | A full sync finishes. Payload is the sync object, including orphaned. |
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.
| Header | Contents |
|---|---|
webhook-id | The delivery. The same id on a retry, so deduplicate on it. |
webhook-timestamp | Unix seconds of this attempt. |
webhook-signature | v1,<base64> — HMAC-SHA256 with your secret over "{id}.{timestamp}.{body}". During a rotation there are two, space-separated. |
X-priceq-Signature | sha256=<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.
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"]
}'
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)
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…
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.
| Action | Recorded when |
|---|---|
catalog.api_delivered | Every delivery, with the full summary. |
catalog.api_sync_completed | A sync finished; includes how many were retired. |
catalog.api_sync_aborted | A 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.
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
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 URL | https://app.priceq.io/api/v1 — one environment; there is no sandbox. Test with a read-scoped key, or a separate organisation. |
| OpenAPI | The customer-facing schema is available as a downloadable file (openapi-public.json) for Postman and code generators. The live /openapi.json is not exposed. |
| Status | Incidents and uptime on our public status page (link on the marketing site). |
| Support | [email protected], answered within one working day. |
| Security | Suspect 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
| Date | Change |
|---|---|
| 2026-09-10 | Keys 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-09 | Catalogue 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-12 | Customer API keys. Reports as Excel workbooks. |
# 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]