Public API

Read-only access to grocery and drogerie product prices across 20+ Croatian retailers. JSON over HTTPS. Authenticated with a Bearer token; each key is per-minute rate limited.

Getting a key

Email api@cijena-hr.com with your use case and the volume you expect. Keys are issued on a per-partner basis with a custom rate limit. Free tier defaults to 60 requests/minute.

Authentication

Send your token in the Authorization header on every request.

Authorization: Bearer pck_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Endpoints

GET /v1/public/health(no auth)

Unauthenticated heartbeat. Returns server time so you can detect your own clock skew before signing requests. Safe to poll.

curl "https://api.cijena-hr.com/v1/public/health"
GET /v1/public/search?q=<query>

Search products by name. Returns up to limit results (default 30, max 100) with all live offers and stock signal.

curl "https://api.cijena-hr.com/v1/public/search?q=mlijeko&limit=5" \
  -H "Authorization: Bearer $PCK_TOKEN"
GET /v1/public/products/:id

Full offer list and metadata for a single product UUID.

curl "https://api.cijena-hr.com/v1/public/products/<product-id>" \
  -H "Authorization: Bearer $PCK_TOKEN"
GET /v1/public/products/:id/history?days=30

Daily minimum price across all listings for a product, last days days (default 30, max 365). One point per day with at least one observation.

curl "https://api.cijena-hr.com/v1/public/products/<product-id>/history?days=90" \
  -H "Authorization: Bearer $PCK_TOKEN"
GET /v1/public/products/:id/by-store

Per-store prices for a product over the last 14 days. Only stores with crawler-attributed observations and lat/lon are returned. Useful for finding the cheapest local branch.

curl "https://api.cijena-hr.com/v1/public/products/<product-id>/by-store" \
  -H "Authorization: Bearer $PCK_TOKEN"
GET /v1/public/usage?days=7

Your own consumption over the last days days (default 7, max 90): total request count and a breakdown by status code, plus your current per-minute rate limit.

curl "https://api.cijena-hr.com/v1/public/usage?days=30" \
  -H "Authorization: Bearer $PCK_TOKEN"
GET /v1/public/retailers

All tracked retailers with country code, base URL, and category tags.

curl "https://api.cijena-hr.com/v1/public/retailers" \
  -H "Authorization: Bearer $PCK_TOKEN"

Rate limits

  • Default 60 requests/minute per key. Custom on request.
  • Every successful response includes X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset (epoch seconds when the counter resets).
  • On HTTP 429 we also send Retry-After in seconds. Body is {"limit":60,"window":"1m"}.
  • Counter is per key, sliding window, resets 60 seconds after the first hit.

Response shape

{
  "query": "mlijeko",
  "resultCount": 12,
  "results": [
    {
      "productId": "uuid",
      "name": "Dukat Mlijeko 2.8% 1L",
      "brand": "Dukat",
      "ean": "3858881212345",
      "imageUrl": "https://...",
      "lowestPrice": 1.29,
      "highestPrice": 1.59,
      "offerCount": 5,
      "inStockOfferCount": 4,
      "allOutOfStock": false,
      "offers": [
        {
          "retailerName": "Lidl",
          "price": 1.29,
          "currency": "EUR",
          "url": "https://www.lidl.hr/...",
          "observedAt": "2026-04-28T05:00:00.000Z",
          "availability": "IN_STOCK"
        }
      ]
    }
  ]
}

Webhooks

Subscribe to events with a partner-managed URL. We POST a JSON body and sign it with HMAC-SHA256 of the body using your webhook's secret as the key. Verify by computing sha256=<hex> and comparing against the X-Webhook-Signature header in constant time.

Currently emitted: price.drop (fires when a tracked listing drops more than the configured threshold within the alerts window).

POST /v1/public/webhooks

Register a new webhook. Body: { url, events?: string[] }. Default events list is ["price.drop"]. Response includes the secret exactly once — store it now.

curl -X POST "https://api.cijena-hr.com/v1/public/webhooks" \
  -H "Authorization: Bearer $PCK_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://your-app.example/webhooks/pck"}'
GET /v1/public/webhooks / DELETE /v1/public/webhooks/:id

List your active webhooks (no secret returned), or revoke one by id.

POST /v1/public/webhooks/:id/test

Fires a synthetic price.drop delivery to your webhook so you can verify signature handling end-to-end without waiting for a real alert. Uses the same retry semantics and writes a row to the delivery history.

curl -X POST "https://api.cijena-hr.com/v1/public/webhooks/<id>/test" \
  -H "Authorization: Bearer $PCK_TOKEN"
GET /v1/public/webhooks/:id/deliveries

Last 50 delivery attempts (capped at 200 via ?limit=) for one of your webhooks. Returns event, status, attempts, error text, duration. 404 if it doesn't belong to you.

curl "https://api.cijena-hr.com/v1/public/webhooks/<id>/deliveries" \
  -H "Authorization: Bearer $PCK_TOKEN"

Verifying a delivery (Node)

import crypto from "node:crypto";

app.post("/webhooks/pck", express.raw({ type: "*/*" }), (req, res) => {
  const sig = req.header("X-Webhook-Signature") ?? "";
  const expected =
    "sha256=" + crypto.createHmac("sha256", process.env.PCK_WEBHOOK_SECRET)
      .update(req.body) // raw Buffer
      .digest("hex");
  if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
    return res.sendStatus(401);
  }
  const { event, data } = JSON.parse(req.body.toString());
  // … handle event …
  res.sendStatus(200);
});

Delivery semantics

  • Up to 3 attempts: immediate, +5s, +30s.
  • 5xx and network errors retry; 4xx do not (config error on your end).
  • 8s per-attempt timeout. Respond fast and queue work async.
  • At-least-once delivery — dedupe on your side using the payload id if needed.

Node.js example

Drop this into a script — it'll fetch the cheapest offer for a query and bail out gracefully on rate-limit responses.

const BASE = "https://api.cijena-hr.com";
const TOKEN = process.env.PCK_TOKEN;
if (!TOKEN) throw new Error("Set PCK_TOKEN env var");

async function findCheapest(query) {
  const url = new URL("/v1/public/search", BASE);
  url.searchParams.set("q", query);
  url.searchParams.set("limit", "1");
  const res = await fetch(url, {
    headers: { Authorization: `Bearer ${TOKEN}` },
  });
  if (res.status === 429) {
    const body = await res.json();
    throw new Error(`Rate limited (${body.limit}/${body.window})`);
  }
  if (!res.ok) throw new Error(`API ${res.status}`);
  const { results } = await res.json();
  return results[0] ?? null;
}

const product = await findCheapest("mlijeko");
console.log(product?.lowestPrice, "EUR @", product?.offers[0]?.retailerName);

Errors

  • 401 — missing, malformed, or revoked token
  • 400 — invalid query parameter
  • 404 — product UUID not found
  • 429 — rate limit exceeded
Need a higher limit, custom fields, or webhooks for price changes? Email api@cijena-hr.com.