↑ ↓ to move ↵ to open
Guides 11 min read The Upscrape Team

Build a Blinkit price tracker in Python

Track the price, MRP, discount and stock of exact Blinkit products at the pincodes you choose, keep a daily history in SQLite, and flag what changed. Every example uses live Blinkit responses from September 2026.

Bar chart of the first 7 of 12 Blinkit search results for optimum nutrition at pincode 560102, with price. The 907 g tub this guide tracks is result 3; result 1 is the 2.27 kg tub at ₹11,682.

This Blinkit price tracker is a Python script that records the price, MRP, discount and stock of fixed Blinkit product IDs at the pincodes you choose, keeps a daily history in SQLite and flags what changed. For 2 products at 2 pincodes it costs 10 credits a day, about 1 to 2 US cents, and every Blinkit figure below comes from live responses collected on 23 September 2026.

It is written for people who collect data for an FMCG ecommerce team, a market-research firm or a product that shows price and availability. The question the tracker answers every day is:

What happened to this exact product, at this exact delivery location, on this date?

Blinkit data depends on location. As the Upscrape Blinkit API page explains, a pincode resolves to a representative delivery point and its serving merchants, and different addresses in one pincode can see different prices or stock. So the script fixes the product by its ID and the place by a resolved delivery point, and never lets a search result choose either.

The examples track two global-brand protein products, Optimum Nutrition Gold Standard 100% Whey (907 g) and a Nature Valley protein bar pack, at HSR Layout (560102) and Whitefield (560066) in Bengaluru. Swap in your own products once the tracker works.

The tracker uses three Upscrape capabilities: blinkit.location to resolve the pincode (1 credit), blinkit.product to read an exact product (2 credits), and blinkit.search only when you first need to find product IDs (1 to 9 credits depending on limit: 1 credit per 12 products requested). The Blinkit API reference always shows the current price of each.

Decide what one row means

Before writing the loop, decide what one observation means.

For price history you need more than product_id and pincode. Keep the coordinates the pincode resolved to, the merchants involved and the time you collected the observation. With those, a price move can be traced to the merchant that served it.

A practical row looks like this:

Field Why keep it
product_id Keeps the product identity fixed across runs
label and variant Makes the stored ID readable and catches pack-size mistakes
pincode The location the business asked about
latitude, longitude The representative delivery point actually queried
location_merchant_id The main merchant Blinkit reports for that point
product_merchant_id The merchant that actually served this product, which can be a different one
price, mrp, discount_pct The commercial price observation
stock_state, inventory Availability, kept apart from price
run_status, error Stops failed requests from becoming fake stockouts
captured_at Turns individual requests into a time series

Missing data stays missing. The blinkit.product reference explains nullable prices and availability fields. A missing price is not ₹0; store null as empty and preserve a real zero. The tracker reads stock_status, available, is_sold_out and inventory, keeping missing or conflicting availability signals unknown.

Resolve the pincode before you read the product

You can send a pincode directly to any Blinkit capability, but for a tracker it helps to make location resolution explicit.

Call blinkit.location first. For a serviceable pincode it returns the representative latitude and longitude, the main merchant ID, the merchants Blinkit reports for that point, and address details. The blinkit.location reference warns that a pincode stands for one point in the area. If Blinkit does not serve the point, or the pincode does not resolve, the request fails with the error code not_found.

Then use the returned coordinates for every exact product check in that run. Each daily batch follows the same path:

pincode -> resolved delivery point -> exact product ID -> observation

It also gives you something to audit, because merchants change. These are the merchants Upscrape observed at the two pincodes in this guide:

Pincode Main merchant on 21 Sep Main merchant on 23 Sep Served both products on 23 Sep
HSR Layout, 560102 40589 36475 42814
Whitefield, 560066 45631 45631 45631

At HSR Layout the main merchant changed between the two checks, at the same coordinates, and both products came from a third merchant, 42814. In Whitefield everything came from the point's main merchant. The tracker keeps both IDs, so a price change that coincides with a merchant change is visible as such.

Discover product IDs once, then stop searching for identity

Search is useful when you set up the tracker. After that, the product ID decides what you track.

Suppose you want to track Optimum Nutrition Gold Standard 100% Whey, Double Rich Chocolate, 907 g. Make a one-time blinkit.search request:

Shell
curl --request POST \
  --url https://data.upscrape.com/execute \
  --header "Authorization: Bearer $UPSCRAPE_API_KEY" \
  --header "Content-Type: application/json" \
  --header "Prefer: wait=30" \
  --data '{
    "capability": "blinkit.search",
    "input": {
      "pincode": "560102",
      "query": "optimum nutrition",
      "limit": 12
    }
  }'

Each search result includes the product ID, name, pack size, price, MRP, inventory and merchant ID. When Upscrape ran this search on 23 September, the first result was a different product:

Search results for “optimum nutrition” at 560102, in search order, with price All 12 results are Optimum Nutrition products. The highlighted row is the one this guide tracks.
  1. 1 Gold Standard Whey, Double Rich Chocolate 2.27 kg ₹11,682
  2. 2 Gold Standard Whey, Double Rich Chocolate 152 g ₹1,079
  3. 3 Gold Standard Whey, Double Rich Chocolate 907 g · product 512941 ₹5,079 The product this guide tracks
  4. 4 Gold Standard Whey, Chocolate 454 g ₹2,759
  5. 5 Micronised Creatine, Citrus Orange 250 g ₹925
  6. 6 Serious Mass Gainer, Chocolate 1 kg ₹1,499
  7. 7 Gold Standard Whey, Vanilla 907 g ₹5,079
  8. 8 Whey Protein Powder 1 kg ₹3,799
  9. 9 Serious Mass Gainer, Chocolate 3 kg ₹4,099
  10. 10 Gold Standard Whey, Alphonso Mango 907 g ₹5,079
  11. 11 Performance Whey, Chocolate Milkshake 500 g ₹2,149
  12. 12 Micronised Creatine, Unflavoured 250 g ₹925

Result 1 is the 2.27 kg tub. Three 907 g tubs, in Double Rich Chocolate, Vanilla and Alphonso Mango, show the same ₹5,079, so neither the position nor the price identifies the product. Only the product ID does.

Source: Upscrape blinkit.search at pincode 560102, 23 September 2026, 02:02 IST.

Inspect the name and pack size, confirm the product you want, and put its ID into your tracked-product configuration. Here that is 512941, at ₹5,079 against an MRP of ₹6,319 on 23 September. The second product in this guide, Nature Valley Crunchy Oats & Honey Protein Bar Pack (5 x 42 g), was found the same way: product 475798.

From then on, use blinkit.product.

A daily tracker that searched for "optimum nutrition" and took result number one would have tracked a tub more than twice the size. Ranking changes, other pack sizes and flavours appear, and the product you want can drop out of the result set. With the ID fixed, the time series describes the same product every day.

Build the daily Blinkit price tracker

Save the following as blinkit_tracker.py. It needs Python 3.9 or later and the requests package:

Shell
pip install requests
# Windows only: Python there has no time zone database of its own
pip install tzdata
export UPSCRAPE_API_KEY="your_api_key"

Replace PINCODES and PRODUCTS with your own locations and confirmed product IDs.

The script handles both inline results and queued jobs. As the jobs reference describes, completed work returns HTTP 200, while HTTP 202 means the job is still running and should be polled. A job that fails while you wait returns a non-2xx status with an error code, and a failed job you poll returns HTTP 200 with state: "failed". The script checks the state and the error code in both cases (see errors and retries). Each submission carries its own Idempotency-Key, and a poll that meets a rate limit or a server error waits, honouring Retry-After, and polls again until the deadline.

Python 405 lines
import csv
import os
import sqlite3
import time
import uuid
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo

import requests

API = "https://data.upscrape.com"
KEY = os.environ["UPSCRAPE_API_KEY"]
DB = "blinkit_tracker.sqlite3"
CSV = "blinkit_latest.csv"
IST = ZoneInfo("Asia/Kolkata")

PINCODES = ["560102", "560066"]
PRODUCTS = [
    {
        "product_id": 512941,
        "label": "Optimum Nutrition Gold Standard Whey Double Rich Chocolate 907 g",
    },
    {
        "product_id": 475798,
        "label": "Nature Valley Crunchy Oats & Honey Protein Bar Pack 5 x 42 g",
    },
]

FIELDS = [
    "captured_at", "run_date", "pincode", "latitude", "longitude",
    "location_merchant_id", "product_id", "label", "name", "variant",
    "price", "mrp", "discount_pct", "stock_state", "inventory",
    "product_merchant_id", "run_status", "error", "change_flag", "changes",
]

# Polls that hit these are retried. The stop codes end the run.
RETRY_STATUS = {429, 500, 502, 503, 504}
STOP_CODES = {"unauthorized", "quota_exhausted", "account_inactive"}


class UpscrapeError(RuntimeError):
    def __init__(self, code, message):
        super().__init__(f"{code}: {message}")
        self.code = code


def raise_job_error(r):
    try:
        body = r.json()
    except ValueError:
        body = {}

    err = body.get("error") or {}
    raise UpscrapeError(
        err.get("code") or f"http_{r.status_code}",
        err.get("message") or r.text[:300],
    )


def execute(capability, input_data, deadline=90):
    headers = {
        "Authorization": f"Bearer {KEY}",
        "Content-Type": "application/json",
        "Prefer": "wait=30",
        "Idempotency-Key": str(uuid.uuid4()),
    }

    r = requests.post(
        f"{API}/execute",
        headers=headers,
        json={"capability": capability, "input": input_data},
        timeout=40,
    )

    started = time.monotonic()
    delays = [1, 2, 4, 8, 10]
    attempt = 0
    job_id = None

    while True:
        if r.status_code == 200:
            body = r.json()
            if body.get("state") == "completed" and body.get("success") is True:
                results = body.get("results") or []
                if not results:
                    raise UpscrapeError("empty_result", "completed job had no results")
                return results[0].get("data") or {}
            # A polled job that failed arrives as HTTP 200 with state "failed".
            raise_job_error(r)

        if r.status_code == 202:
            job_id = r.json().get("job_id")
            if not job_id:
                raise UpscrapeError("missing_job_id", "pending response had no job_id")
        elif job_id is None:
            # Rejected requests and jobs that failed during the wait.
            raise_job_error(r)
        elif r.status_code not in RETRY_STATUS:
            # A 404 here means the job ID is unknown, not the location.
            raise UpscrapeError(f"poll_http_{r.status_code}", r.text[:300])

        if time.monotonic() - started >= deadline:
            raise UpscrapeError(
                "local_timeout",
                f"job {job_id} still pending after {deadline}s; "
                "poll it later instead of resubmitting",
            )

        wait = delays[min(attempt, len(delays) - 1)]
        if r.status_code in RETRY_STATUS:
            wait = max(wait, float(r.headers.get("Retry-After") or 0))
        time.sleep(wait)
        attempt += 1

        r = requests.get(
            f"{API}/jobs/{job_id}",
            headers={"Authorization": f"Bearer {KEY}"},
            timeout=20,
        )


def stop_if_fatal(exc):
    # A bad key or an empty balance says nothing about Blinkit.
    if getattr(exc, "code", None) in STOP_CODES:
        raise SystemExit(f"stopping the run: {exc}")


def init_db(conn):
    conn.execute("""
        CREATE TABLE IF NOT EXISTS observations (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            captured_at TEXT NOT NULL,
            run_date TEXT NOT NULL,
            pincode TEXT NOT NULL,
            latitude REAL,
            longitude REAL,
            location_merchant_id TEXT,
            product_id INTEGER NOT NULL,
            label TEXT NOT NULL,
            name TEXT,
            variant TEXT,
            price INTEGER,
            mrp INTEGER,
            discount_pct REAL,
            stock_state TEXT NOT NULL,
            inventory INTEGER,
            product_merchant_id TEXT,
            run_status TEXT NOT NULL,
            error TEXT,
            change_flag INTEGER NOT NULL,
            changes TEXT
        )
    """)
    conn.commit()


def row_template(now, pincode, product, location=None):
    location = location or {}

    return {
        "captured_at": now.isoformat(timespec="seconds"),
        "run_date": now.date().isoformat(),
        "pincode": pincode,
        "latitude": location.get("latitude"),
        "longitude": location.get("longitude"),
        "location_merchant_id": location.get("merchant_id"),
        "product_id": product["product_id"],
        "label": product["label"],
        "name": None,
        "variant": None,
        "price": None,
        "mrp": None,
        "discount_pct": None,
        "stock_state": "unknown",
        "inventory": None,
        "product_merchant_id": None,
        "run_status": "error",
        "error": None,
        "change_flag": 0,
        "changes": None,
    }


def apply_product(row, product):
    price = product.get("price")
    mrp = product.get("mrp")
    inventory = product.get("inventory")

    # Keep unknown and contradictory availability signals separate from stockouts.
    signals = set()
    status = product.get("stock_status")
    if status in ("in_stock", "low_stock"):
        signals.add("in_stock")
    elif status == "out_of_stock":
        signals.add("sold_out")
    if isinstance(product.get("available"), bool):
        signals.add("in_stock" if product["available"] else "sold_out")
    if isinstance(product.get("is_sold_out"), bool):
        signals.add("sold_out" if product["is_sold_out"] else "in_stock")
    if isinstance(inventory, (int, float)) and not isinstance(inventory, bool) and inventory > 0:
        signals.add("in_stock")
    stock = signals.pop() if len(signals) == 1 else "unknown"

    if (
        isinstance(price, (int, float))
        and isinstance(mrp, (int, float))
        and mrp > 0
    ):
        discount = round((mrp - price) / mrp * 100, 2)
    else:
        discount = None

    row.update({
        "name": product.get("name"),
        "variant": product.get("variant"),
        "price": price,
        "mrp": mrp,
        "discount_pct": discount,
        "stock_state": stock,
        "inventory": inventory,
        "product_merchant_id": product.get("merchant_id"),
        "run_status": "ok",
    })


COMPARED = [
    "run_status",
    "price",
    "mrp",
    "discount_pct",
    "stock_state",
    "location_merchant_id",
    "product_merchant_id",
]


def previous_day(conn, row):
    yesterday = (
        datetime.fromisoformat(row["run_date"]).date()
        - timedelta(days=1)
    ).isoformat()

    found = conn.execute(f"""
        SELECT {', '.join(COMPARED)}
        FROM observations
        WHERE pincode=? AND product_id=? AND run_date=?
        ORDER BY captured_at DESC
        LIMIT 1
    """, (
        row["pincode"],
        row["product_id"],
        yesterday,
    )).fetchone()

    if not found:
        return None

    return dict(zip(COMPARED, found))


def compare(previous, current):
    if previous is None:
        return 0, "no_baseline"

    changes = []

    if previous["run_status"] != current["run_status"]:
        changes.append(
            f"status {previous['run_status']} -> {current['run_status']}"
        )

    if previous["run_status"] == current["run_status"] == "ok":
        for field in ("price", "mrp", "discount_pct", "stock_state"):
            if previous.get(field) != current.get(field):
                changes.append(
                    f"{field} "
                    f"{previous.get(field)} -> {current.get(field)}"
                )

    for field in ("location_merchant_id", "product_merchant_id"):
        before = previous.get(field)
        after = current.get(field)

        if before and after and before != after:
            changes.append(f"{field} {before} -> {after}")

    return (
        int(bool(changes)),
        "; ".join(changes) if changes else "no_change",
    )


def collect():
    now = datetime.now(IST)
    rows = []

    for pincode in PINCODES:
        try:
            location = execute(
                "blinkit.location",
                {"pincode": pincode},
            )
        except Exception as exc:
            stop_if_fatal(exc)
            # not_found: Blinkit does not serve this point,
            # or the pincode did not resolve.
            if getattr(exc, "code", None) == "not_found":
                status = "location_not_found"
            else:
                status = "error"

            for product in PRODUCTS:
                row = row_template(now, pincode, product)
                row.update(run_status=status, error=f"location: {exc}")
                rows.append(row)
            continue

        lat = location.get("latitude")
        lon = location.get("longitude")

        if lat is None or lon is None:
            for product in PRODUCTS:
                row = row_template(
                    now,
                    pincode,
                    product,
                    location,
                )
                row["error"] = "location returned no coordinates"
                rows.append(row)
            continue

        for product in PRODUCTS:
            row = row_template(
                now,
                pincode,
                product,
                location,
            )

            try:
                data = execute(
                    "blinkit.product",
                    {
                        "product_id": product["product_id"],
                        "latitude": lat,
                        "longitude": lon,
                    },
                )

                apply_product(
                    row,
                    data.get("product") or {},
                )

            except Exception as exc:
                stop_if_fatal(exc)
                row["error"] = f"product: {exc}"

            rows.append(row)

    return rows


def main():
    rows = collect()

    with sqlite3.connect(DB) as conn:
        init_db(conn)

        for row in rows:
            row["change_flag"], row["changes"] = compare(
                previous_day(conn, row),
                row,
            )

            conn.execute(
                f"INSERT INTO observations "
                f"({', '.join(FIELDS)}) "
                f"VALUES ({', '.join('?' for _ in FIELDS)})",
                [row[field] for field in FIELDS],
            )

        conn.commit()

    with open(CSV, "w", newline="", encoding="utf-8") as f:
        writer = csv.DictWriter(
            f,
            fieldnames=FIELDS,
        )
        writer.writeheader()
        writer.writerows(rows)

    for row in rows:
        if row["change_flag"]:
            print(
                row["pincode"],
                row["label"],
                row["run_status"],
                row["changes"],
            )


if __name__ == "__main__":
    main()

What the first run records

Given the Blinkit responses collected on 23 September 2026, the script prints nothing on its first run, because there is no baseline yet, and writes these rows. The table shows a selection of the columns; discount_pct is 19.62 for the whey and 12.0 for the bar pack.

Pincode Product Price MRP Stock Inventory Location merchant Product merchant
560102 512941 5079 6319 in_stock 4 36475 42814
560102 475798 330 375 in_stock 1 36475 42814
560066 512941 5079 6319 in_stock 1 45631 45631
560066 475798 330 375 in_stock 2 45631 45631

The prices match across the two pincodes, but the merchants and inventory differ. A tracker that stored only price would miss that the HSR Layout rows came from a different merchant than the point's main one.

What the script refuses to infer

A product becomes sold_out when the response provides an out-of-stock signal, and in_stock when it provides an in-stock or low-stock signal. Positive inventory also counts as in-stock evidence. Missing or contradictory signals stay unknown; inventory zero alone is not a stockout. Prices are recorded separately, including a real zero when returned.

An API failure stays run_status=error, and a pincode that Blinkit does not serve, or that does not resolve, becomes location_not_found. Neither is recorded as sold out, and failed requests are not charged. An invalid key, an inactive account or an empty balance stops the run without writing any rows, because those errors say nothing about Blinkit.

If a job is still running when the script's 90-second polling deadline passes, the script records an error but does not cancel the job, which may still complete and be charged. Do not resubmit it in the same run; tomorrow's run is a new observation.

That separation keeps operational failures out of your availability metrics.

A missing search result is not a stockout

Search visibility and exact-product availability are different signals, and the tracker keeps them apart for a concrete reason. At HSR Layout on 23 September, Upscrape searched for protein bar with a limit of 50 and then checked the Nature Valley pack directly:

Brands in the first 50 results for “protein bar” at 560102 Results per brand. The highlighted row is the product checked directly.
  • The Whole Truth 10
  • SuperYou 9
  • Yoga Bar 9
  • RiteBite 5
  • Avvatar 4
  • Avolt 3
  • Phab 3
  • Stroom 3
  • Upnourish by Pluckk 2
  • Green Protein 1
  • Supply6 1
  • Nature Valley product 475798 0 Not in the 50 results. The exact product check at the same point, two minutes later, returned it in stock with 1 unit.

A search for “nature valley” at the same point returned the pack in second place.

Source: Upscrape blinkit.search and blinkit.product at pincode 560102, 23 September 2026, 02:01 to 02:03 IST.

If a daily tracker had read "not in the protein bar results" as "out of stock", it would have written the wrong fact into its history.

The reverse holds too: a returned price says nothing on its own about whether the product can be bought right now, so price and availability stay in separate columns and are read separately.

Search still has value. It shows what a shopper sees for a query and where a product ranks. Keep that as its own dataset, beside exact product availability.

Run it once a day

On a Linux host whose scheduler runs in India time, a cron entry runs the tracker every morning:

Cron
15 7 * * * cd /path/to/tracker && /path/to/.venv/bin/python blinkit_tracker.py >> tracker.log 2>&1

If the machine uses another timezone, set the scheduler's timezone explicitly instead of assuming server time is IST.

Each run creates or updates two files:

  • blinkit_tracker.sqlite3 is the append-only history used for day-over-day comparison.

  • blinkit_latest.csv holds only the current batch, ready to hand to an analyst or load into another system.

On the first day every row gets changes=no_baseline and the script prints nothing. On later days it prints only the rows that changed, with flags such as price 330 -> 315, stock_state in_stock -> sold_out or location_merchant_id 40589 -> 36475. That last flag is how the HSR Layout merchant change between 21 and 23 September would appear on two consecutive daily runs.

The script compares with the previous calendar day, not the most recent successful response. If yesterday failed, today's row should say so. Otherwise a three-day gap could pass for an ordinary one-day comparison.

What does a daily Blinkit price tracker cost?

A blinkit.location request costs 1 credit and a blinkit.product request costs 2. If you track N products at M pincodes once a day, a normal run costs:

Requests per day Credits This guide: 2 products, 2 pincodes
Product checks 2 × N × M 8
Location checks M 2
Total per day 2 × N × M + M 10

That is about 300 credits a month for this guide's basket, or about $0.38 to $0.59 a month at current pack rates: 300 credits at $1.25 per 1,000 on the 400,000-credit pack is $0.375, and at $1.96 per 1,000 on the 25,000-credit pack it is $0.588. These are the prices published on 24 September 2026; the reference pages always show the current ones.

The location calls are worth keeping because they record the delivery point and merchant you are comparing.

Failed requests, including a pincode Blinkit does not serve, never consume credits under the current pricing. Pack prices and terms can change, so check the Upscrape pricing page rather than hard-coding a rupee or dollar cost into the tracker.

If you also run blinkit.search every day for search-rank analysis, budget those calls separately: a search costs 1 credit per 12 products requested, so the 12-product search above is 1 credit and a 50-product search is 5. The fixed-product history built here does not need them.

Turn the snapshots into Blinkit price history

Upscrape's Blinkit endpoints return current observations, so Blinkit price history comes from scheduling repeated observations and keeping them yourself.

After seven daily runs, inspect the history directly:

SQL
SELECT
    run_date,
    pincode,
    product_id,
    label,
    price,
    mrp,
    discount_pct,
    stock_state,
    run_status,
    changes
FROM observations
WHERE run_date >= date('now', '+330 minutes', '-6 days')
ORDER BY product_id, pincode, run_date;

date('now') in SQLite is UTC, while run_date is an India date, so the query shifts by 330 minutes (five and a half hours) before counting back six days.

For analysis, group by product and pincode together. A useful seven-day price chart draws one series per product and location, shows stock events beside the price series, and draws missing prices as gaps.

For an ecommerce account team, the history then answers questions such as whether the MRP changed or only the selling price, whether a discount appeared only at some delivery points, whether a stockout was limited to one location, and whether a price move coincided with a different merchant.

SQLite is enough for a small fixed basket. At higher volume the same row structure moves into Postgres, a warehouse or your existing pipeline without changing the identity rules.

Three rules keep the history usable

  1. Resolve location before collecting the product. The pincode is the business geography you asked about; the returned coordinates and merchants describe the observation you actually made. Keep both.

  2. Track exact product IDs. Use search to find IDs and confirm names and pack sizes, then fix the IDs in configuration. Product identity comes from the ID, whatever the search order.

  3. Preserve uncertainty. sold_out, unknown, error and location_not_found mean different things. Merging them makes a dashboard simpler and the history less trustworthy.

These rules matter more as the dataset grows. Once hundreds of daily snapshots feed reports or customer-facing features, fixing identity or state rules after the fact is much harder than getting them right in the first run.

Each daily run then answers the same well-defined question: what did Blinkit return for this exact product, at this resolved delivery point, at this time?