# Quick-commerce Proxy > A FastAPI wrapper around Blinkit, Swiggy Instamart, Zepto, Nykaa, First Club, Flipkart Minutes, and Amazon Now for AI-agent consumption. Returns clean, structured product JSON. This service translates HTTP endpoints into clean JSON for AI agents. Upstreams: - **Blinkit** (India quick-commerce) — `GET /search` - **Swiggy Instamart** (optional) — `GET /instamart/search`, enabled when `SWIGGY_TID` is set in `.env` - **Zepto** (optional) — `GET /zepto/search`, enabled when `ZEPTO_ENABLED=1` - **First Club** (optional) — `GET /firstclub/search`, enabled only with an authorized account and selected address - **Flipkart Minutes** (optional) — `GET /flipkart-minutes/search`, `GET /flipkart-minutes/categories`, anonymous and enabled when `FLIPKART_MINUTES_ENABLED=1` - **Amazon Now** (optional) — `GET /amazon-now/search`, `GET /amazon-now/categories`, enabled with an authorized Amazon session and selected address - **Nykaa Beauty** — `GET /nykaa/categories`, `/nykaa/search`, `/nykaa/category`, and `/nykaa/product`; national catalog inventory with optional PIN-code delivery context Blinkit auth is a pool of `v2::`-prefixed session tokens captured from the Blinkit iOS app; on 401, the service rotates to the next healthy token. There is no silent refresh — when all tokens die, a human must re-authenticate via OTP and add a fresh token to `tokens.json`. Swiggy auth is a 30-day `tid` JWT (header) plus auto-minted AWS WAF tokens. When the `tid` expires, re-capture it from the Swiggy iOS app. Base URL (production): `https://qcomm.scaledfocus.com` Base URL (local dev): `http://127.0.0.1:8765` ## Authentication (client → proxy) All non-public endpoints require an API key. Send it on every request: ``` X-API-Key: ``` If the `API_KEYS` env var is empty on the server, the proxy runs in "open mode" and the header is not enforced (logged as a warning on startup). | Endpoint | Requires `X-API-Key`? | |---|---| | `GET /health` | No | | `GET /docs`, `/openapi.json`, `/llms.txt`, `/ui/*` | No | | `GET /auth/status` | **Yes** | | `POST /auth/rotate` | **Yes** | | `GET /search` | **Yes** | | `GET /product` | **Yes** | | `GET /instamart/search` | **Yes** | | `GET /instamart/product` | **Yes** | | `GET /zepto/product` | **Yes** | | `GET /firstclub/search`, `/firstclub/category`, `/firstclub/categories` | **Yes** | | `GET /flipkart-minutes/search` | **Yes** | | `GET /flipkart-minutes/categories` | **Yes** | | `GET /amazon-now/search`, `/amazon-now/categories` | **Yes** | | `GET /nykaa/categories`, `/nykaa/search`, `/nykaa/category`, `/nykaa/product` | **Yes** | A missing or invalid key returns `401 {"detail": {"error": "invalid_api_key", "message": "..."}}` with `WWW-Authenticate: ApiKey`. This is **client-to-proxy** auth only; it has nothing to do with the proxy's own Blinkit credentials (`auth_key` + `access_token`), which are managed server-side. ## Endpoints ### `GET /health` Process and connector health. The endpoint itself returns 200; use `status` for the aggregate result. Blinkit, Instamart, and Zepto share these operational fields: ```json { "status": "ok", "zepto": { "ready": true, "state": "ready", "available_capacity": 2, "active_searches": 0, "queued": 0 } } ``` `available_capacity` is the number of marketplace searches that can start immediately. It is 0 when the connector is busy, replenishing, or unavailable. Do not infer executable capacity from scheduler telemetry or connector-specific diagnostics. Zepto keeps identity-lane and reserve details under `diagnostics`. ### `GET /ready` Railway readiness gate. Returns the same body as `/health`, with HTTP 200 when all configured connectors meet their safety floor and HTTP 503 otherwise. --- ### `GET /auth/status` Inspect the token pool. Response: ```json { "active_index": 0, "healthy_count": 1, "dead_count": 0, "tokens": [ { "index": 0, "token_preview": "v2::3b9fed86…3cc1", "status": "healthy", "user_id": 48291666, "last_used_at": "2026-05-12T08:15:00+00:00", "last_401_at": null, "marked_dead_at": null, "is_active": true } ] } ``` `status` is one of `"healthy"` or `"dead"`. A dead token is never auto-revived. --- ### `POST /auth/rotate` Manually advance `active_index` to the next healthy token (skipping current). Use for debugging or pre-emptive rotation. Returns the same shape as `/auth/status`. `503 {"detail": {"error": "no_healthy_token", ...}}` if no healthy tokens remain. --- ### `GET /search` Search Blinkit's catalog. The core endpoint. Query parameters: | name | type | required | default | description | |---|---|---|---|---| | `q` | string | yes | — | Search term. e.g. `coconut water`, `amul butter`, `lays chips`. | | `lat` | float | no | config default (12.927298) | Latitude. Drives store selection. | | `lon` | float | no | config default (77.63817) | Longitude. | | `location_name` | string | no | `"Bengaluru"` | Echo-back label. | | `max_pages` | int (1..10) | no | 1 | Pages of results to fetch (~15 products/page). Results are deduped across pages by `product_id`, and fetching stops early once Blinkit's catalog for the query is exhausted (so `pages_fetched` ≤ `max_pages`). Some narrow queries genuinely have only one page. | | `min_price` | int ≥ 0 | no | — | Drop products cheaper than this (INR). | | `max_price` | int ≥ 0 | no | — | Drop products pricier than this (INR). | | `min_rating` | float (0..5) | no | — | Drop products below this `rating_value` (also drops unrated). | | `in_stock_only` | bool | no | false | Drop products where `in_stock` is false. | | `exclude_ads` | bool | no | false | Drop sponsored listings (where `is_ad` is true). | | `brand` | string | no | — | Keep products whose brand contains this substring (case-insensitive). | Response (`SearchResult`): ```json { "platform": "blinkit", "query": "coconut water", "location": {"name": "Bengaluru", "lat": 12.927298, "lon": 77.63817}, "products": [ /* see Product below */ ], "pages_fetched": 1, "total": 15, "token_index_used": 0 } ``` Each `Product`: ```json { "platform": "blinkit", "search_query": "coconut water", "location": "Bengaluru", "latitude": 12.927298, "longitude": 77.63817, "product_id": "448503", "name": "Storia Tender Concentrate Coconut Water", "brand": "Storia", "quantity": "1 ltr", "pack_info": null, "mrp": 178, "price": 99, "offer_text": null, "rating_value": 4.7, "rating_count": 53269, "in_stock": true, "inventory": 30, "merchant_id": 31530, "merchant_type": "express", "product_position": 3, "is_ad": false, "ptype": "Concentrate Coconut Water", "highlights": ["No Added Sugar"], "images": [ "https://cdn.grofers.com/da/cms-assets/cms/product/5004668e-...-17b2ee623fb5.png" ], "scraped_at": "2026-05-12T08:30:00+00:00" } ``` Notes for consumers: - `total` is the number of products **after** cross-page dedup and after any `min_price`/`brand`/etc. filters — not a raw page-size sum. `pages_fetched` is how many upstream pages were actually fetched (≤ `max_pages`; fewer if the catalog ran out). - To get the full catalog for a query, set `max_pages` high (max 10); the proxy walks Blinkit's cursor and stops on its own when there's nothing new. Expect ~15 unique products per page (e.g. `max_pages=8` ≈ 120 products for a broad query like "mayonnaise"). - `inventory` — units in stock at the serving dark store (from Blinkit's own tracking attributes). Use it for stock-level monitoring; `in_stock` is just `inventory > 0` from the buyer's perspective. - `rating_value` and `rating_count` are `null` for unrated products (fresh produce, new listings). Always null-check. - `images[]` are CDN URLs with query params stripped (`?bg_token=...` removed). - `product_position` is the upstream ad-aware ranking, not a filter index. - `is_ad` is true for sponsored placements; they always appear early in the list. - `merchant_id` / `merchant_type`: Blinkit serves results from one or more nearby stores (typically `merchant_type: "express"`). Different `lat`/`lon` will hit different stores and therefore different catalogs. Errors: - `422` — Pydantic validation failure (missing `q`, `max_pages` out of range, etc.). - `502 {"detail": {"error": "upstream_error", ...}}` — Blinkit returned an unexpected status or Cloudflare retries exhausted. - `503 {"detail": {"error": "all_tokens_dead", ...}}` — Every token in the pool 401'd. Capture a fresh token and add to `tokens.json`. --- ### `GET /instamart/search` Search Swiggy Instamart's catalog via `disc.swiggy.com`. **Requires `SWIGGY_TID` in `.env`** — returns `503 swiggy_disabled` if unset. Rate-limited to **30 requests per 60 seconds** per `X-API-Key` (or per client IP when the proxy runs in open mode). Exceeding the limit returns `429` with `Retry-After` and `{"detail": {"error": "rate_limit_exceeded", "retry_after_seconds": N, ...}}`. Query parameters: | name | type | required | default | description | |---|---|---|---|---| | `q` | string | yes | — | Search term. e.g. `milk`, `coconut water`. | | `lat` | float | no | `swiggy.yaml` default (`12.92717250561447`) | Latitude. Drives store/pod selection. **Omit both `lat` and `lon` to use this default** — do not pass `0`/`0`. | | `lon` | float | no | `swiggy.yaml` default (`77.6381699494297`) | Longitude. | | `location_name` | string | no | `"Bengaluru"` | Echo-back label. | | `offset` | int ≥ 0 | no | 0 | `search_results_offset` for pagination (~39 products/page). | | `min_price` | int ≥ 0 | no | — | Drop products cheaper than this offer price (INR). | | `max_price` | int ≥ 0 | no | — | Drop products pricier than this offer price (INR). | | `min_rating` | float (0..5) | no | — | Drop products below this `rating_value` (also drops unrated). | | `in_stock_only` | bool | no | false | Drop products where `in_stock` is false. | | `brand` | string | no | — | Keep products whose brand contains this substring (case-insensitive). | Response (`SwiggySearchResult`): ```json { "platform": "swiggy_instamart", "query": "coconut water", "location": {"name": "Bengaluru", "lat": 12.92717250561447, "lon": 77.6381699494297}, "products": [ /* see SwiggyProduct below */ ], "total": 36, "next_offset": "39", "waf_token_minted": true } ``` Each `SwiggyProduct`: ```json { "platform": "swiggy_instamart", "search_query": "coconut water", "location": "Bengaluru", "latitude": 12.92717250561447, "longitude": 77.6381699494297, "product_id": "…", "parent_product_id": "…", "name": "Storia Tender Coconut Water- No Added Sugar Pet bottle", "brand": "Storia", "quantity": "1 ltr", "price": 99, "mrp": 178, "unit_price": "₹99/ltr", "rating_value": 4.7, "rating_count": 30700, "tags": ["Vegan"], "is_ad": false, "in_stock": true, "inventory": 18, "max_allowed_quantity": 18, "low_stock_text": null, "sku_id": "…", "spin_id": "…", "image": "https://instamart-media-assets.swiggy.com/swiggy/image/upload/…", "product_position": 2, "scraped_at": "2026-06-10T08:30:00+00:00" } ``` Notes for consumers: - **Coordinates:** Instamart defaults come from `swiggy.yaml` (Bengaluru). Omitting `lat`/`lon` works and returns products. Passing `lat=0&lon=0` fails with `502 upstream_error` from Swiggy — never use zero coordinates as a placeholder. - `total` is after filters, not raw upstream count. - `next_offset` is Swiggy's cursor for the next page; pass it as `offset` on the follow-up request. Page size is ~39. - `waf_token_minted` is true when a fresh AWS WAF token was minted during this request (first call or after a challenge). - `rating_value` and `rating_count` are parsed from each listing variant's `rating` block. Swiggy sends counts as display strings (`30.7k`) — we normalize to integers. Both are `null` for unrated SKUs (common on fresh produce). - `tags` — product pills from `variationTags` (e.g. `"Vegan"`, `"Low Calorie"`, `"No Added Sugar"`) plus `attributeTags` / `aiAttributeTags`. Empty array when none. - `is_ad` — true when the listing carries Swiggy's `BADGE_TYPE_AD` badge. - `inventory` — exact units in stock at the serving dark store, derived from `cartAllowedQuantity`: when Swiggy's limit message is "That's all we have in stock", `allowedQuantity` is the store's remaining stock. When the message is a per-order cap ("Only N unit(s) … per order") the true stock is ≥ `max_allowed_quantity` and `inventory` is `null`. In practice ~85–90% of listings expose an exact count. - `max_allowed_quantity` — the max units addable to a cart (either the stock count or Swiggy's per-order cap, whichever is lower). - `low_stock_text` — Swiggy's low-stock banner when present, else `null`. - `in_stock` reflects the listed variation (variation-level `inventory.inStock`), not the parent product — a parent can be "in stock" while the listed pack size is sold out. - `image` is a single CDN URL (not an array). Errors: - `401 {"detail": {"error": "tid_expired", ...}}` — the 30-day `tid` JWT expired; re-capture from the Swiggy iOS app. - `429 {"detail": {"error": "rate_limit_exceeded", ...}}` — too many Instamart searches; wait `retry_after_seconds`. - `502 {"detail": {"error": "upstream_error", ...}}` — Swiggy/AWS WAF upstream failure. - `503 {"detail": {"error": "swiggy_disabled", ...}}` — `SWIGGY_TID` not configured. --- ### `GET /firstclub/search`, `/firstclub/categories`, `/firstclub/category` First Club is deliberately opt-in: configure `FIRSTCLUB_ENABLED=1`, `FIRSTCLUB_SESSION_ID`, `FIRSTCLUB_USER_ID`, `FIRSTCLUB_AUTH_USER_ID`, and `FIRSTCLUB_ADDRESS_ID` as server secrets. Its catalogue is tied to that account's selected delivery address, so these routes do not accept arbitrary coordinates. The service resolves the address coordinates from First Club's account API; `FIRSTCLUB_ADDRESS_LAT` and `FIRSTCLUB_ADDRESS_LON` are optional overrides. ### `GET /flipkart-minutes/search`, `/flipkart-minutes/categories` Enable with `FLIPKART_MINUTES_ENABLED=1`. The collector needs no Flipkart account: it creates an anonymous web session per coordinate, resolves the current pincode, and binds that session to the `HYPERLOCAL` marketplace before searching. Location sessions are isolated and cached, so concurrent cities do not overwrite one another's store context. Parameters are `q`, optional `lat`, `lon`, and `location_name`, plus `in_stock_only`, `min_price`, `max_price`, `brand` (a case-insensitive product name substring), and `exclude_ads`. The response includes `pincode`, native `store_id`, exact `product_id`/`listing_id`, rank, price/MRP, stock signal, image, and canonical product URL. Flipkart's server-rendered search surface is one shelf; `pages_fetched` is therefore `1` and `has_reached_end` is unknown. A coordinate with no trustworthy native Minutes store returns HTTP 200 with `serviceable: false` and an empty product list. Transport or upstream failures return 502. Set `FLIPKART_MINUTES_USE_PROXY=0` to opt this connector out of the shared outbound proxy. `/flipkart-minutes/categories` uses the same coordinate-bound session to read Flipkart's native Minutes category tab. It returns the current `category_id`, display name derived from the native product-list URL, canonical `native_url`, and category artwork for every available shelf. The directory is provider-owned and location-specific; callers should refresh it instead of hard-coding IDs. ### `GET /amazon-now/search`, `/amazon-now/categories` Enable with `AMAZON_NOW_ENABLED=1` and an authenticated `AMAZON_NOW_SESSION_COOKIE`. Amazon Now is account- and address-bound, so these routes deliberately do not accept arbitrary coordinates. The account's selected address controls serviceability and inventory; `AMAZON_NOW_ADDRESS_LABEL` and `AMAZON_NOW_PINCODE` provide non-secret response metadata. Search uses Amazon's native `nowstore` index only after the authenticated `/tez/browse` gateway admits the session to Amazon Now. This prevents an expired or ineligible session from silently collecting Amazon Fresh. The result contains native ASIN, title, pack text, price/MRP, rating, availability, sponsored status, image, canonical URL, and one-based rank. `/amazon-now/categories` returns the live provider-owned category identifiers and URLs from the same gateway. Expired sessions return 401, transport or parse failures return 502, and an address outside the service area returns HTTP 200 with `serviceable: false` and an empty list. Set `AMAZON_NOW_USE_PROXY=0` to opt this connector out of the shared outbound proxy. ## Seasonal target discovery `GET /seasonal-targets?platform=blinkit|instamart|zepto|firstclub` is the stable discovery API for provider-owned campaign tabs, seasonal collections, and home destinations. It always returns opaque `target_id`, `target_type`, `native_url`, and destination metadata from the live, location-bound provider response, plus a `scraped_at` timestamp. Do not hard-code festival names: targets can appear, change, or disappear between polls. `/firstclub/categories` returns the native V4 home category entries. Supply a returned `category_id` (the app's page ID) to `/firstclub/category?category_id=…` to retrieve that complete, ranked address-bound shelf. `/firstclub/search?q=…` searches the same address-bound shelf. Every First Club product includes `inventory`, the exact native `availableQuantity` observed at `scraped_at`, plus `inventory_out_of_stock` when the response supplies a nominal quantity. `in_stock` is a convenience flag and is false when First Club marks the listing `ITEM_UNAVAILABLE`, even if a stale quantity remains in the card. --- ### Product pages Each provider exposes a separate product-page endpoint. Product price and stock are always location-specific; pass the same coordinates used for discovery. | Provider | Endpoint | Required identifier | Notes | |---|---|---|---| | Blinkit | `GET /product` | `product_id`, `q` | Blinkit's native PDP opens from its complete product card. `q` is the discovery query that produced the id; `max_pages` controls how far to scan for that exact card. | | Instamart | `GET /instamart/product` | `product_id` | Calls Instamart's native JSON product endpoint. Returns normalized `product` plus provider PDP `details`. | | Zepto | `GET /zepto/product` | `variant_id` | `variant_id` is the `pvid` returned by Zepto search/category. Returns the server-rendered PDP data (`product`, `productVariant`, `storeProduct`, attributes). | All three responses include a `canonical_url`. Zepto keeps the established typed-degraded semantics: a temporary lane recovery returns `202` with `degraded: true`, rather than a connector 5xx. Examples: ```text GET /product?product_id=448503&q=coconut%20water GET /instamart/product?product_id=4RL2JFCWQG GET /zepto/product?variant_id=5096519d-0209-4ec9-861d-656caca8dac9&title=Yu%20Natural%20Coconut%20Water ``` --- ### `GET /zepto/category` Return Zepto's ordered product-listing page (PLP) recommendations. This is separate from `/zepto/search`: labels such as `Makeup & Beauty` are navigation categories, not reliable text-search terms. Query parameters: | name | type | required | default | description | |---|---|---|---|---| | `category_id` | UUID string | yes | — | The `cid` value from a Zepto `/cn/.../cid/{cid}/scid/{scid}` URL. | | `subcategory_id` | UUID string | yes | — | The `scid` value from the same URL. | | `lat` / `lon` | float | no | `zepto.yaml` default | Coordinates that select the serving store and inventory. | | `location_name` | string | no | `"Bengaluru"` | Echo-back label. | | `max_pages` | int (1..10) | no | 1 | Maximum PLP pages. The proxy replays Zepto's opaque cursor and stops at `endOfPage`. | | `in_stock_only` | bool | no | false | Filter after ranking; retained products keep their original `product_position`. | | `exclude_ads` | bool | no | false | Drop sponsored products after ranking. | | `min_price` / `max_price` | int | no | — | Price filters in INR. | | `min_rating` | float | no | — | Minimum rating. Unrated products are dropped. | | `brand` | string | no | — | Case-insensitive brand substring. | Example category URL: ```text /cn/makeup-beauty/top-picks/ cid/2e5eae34-1666-4ddc-8903-76e6547f72e4/ scid/e64390a2-eaaa-4f23-b1a6-422df5e2af60 ``` Response (`ZeptoCategoryResult`): ```json { "platform": "zepto", "category_id": "2e5eae34-1666-4ddc-8903-76e6547f72e4", "subcategory_id": "e64390a2-eaaa-4f23-b1a6-422df5e2af60", "location": {"name": "Bengaluru", "lat": 12.9716, "lon": 77.5946}, "store_id": "...", "products": [/* ZeptoProduct; product_position is PLP recommendation rank */], "total": 13, "pages_fetched": 1, "has_reached_end": true, "next_page_params": null, "serviceable": true, "degraded": false, "error": null, "waf_token_minted": false } ``` `next_page_params` is informational: callers select `max_pages`, while the proxy owns cursor replay. Never synthesize a page number—Zepto's cursor can also carry `last_widget_id`, `disable_tabs`, and feed identifiers. A transient upstream recovery returns a typed degraded response instead of a connector 5xx. --- ## Conventions - All times are UTC ISO-8601 strings. - Money values (`price`, `mrp`) are integers in INR (₹). - The service is *read-only* — there are no mutating product endpoints. Only token state mutates server-side (and only via `/auth/rotate` or via a 401 happening during a `/search`). ## Auth model There is one `auth_key` (device-bound, long-lived) and a pool of `access_token`s (session-bound, format `v2::`, no advertised expiry — they just stop working when revoked). The Blinkit upstream does not expose a silent refresh; new tokens must be obtained via phone-OTP login. The `/auth/send-otp` and `/auth/verify-otp` flow is documented in `captures/login_curls.txt` but is NOT wired up in this version — it's a manual recovery step. ## Outbound network path By default the proxy connects directly to `api2.grofers.com`. In production deployments where the host has a datacenter IP that Blinkit's Cloudflare Bot Management blocks (Railway, AWS, GCP, …), an optional **Oxylabs residential proxy** layer can be enabled by setting `OXYLABS_USERNAME` + `OXYLABS_PASSWORD` env vars (with optional `OXYLABS_COUNTRY` — default `IN`). When enabled, all outbound requests to Blinkit go through a sticky residential IP in the configured country. Status is logged at startup. ## Swiggy Instamart setup Set `SWIGGY_TID` in `.env` (30-day session JWT from the Swiggy iOS app, header `tid`). Static device/WAF config lives in `swiggy.yaml`. AWS WAF tokens are auto-minted via `src/aws_waf/` (Switch3301/Aws-Waf-Solver port) — no manual `SWIGGY_WAF_TOKEN` needed unless you want to seed one. Optional env vars: `SWIGGY_JID`, `SWIGGY_SID` (auto-derived from tid), `SWIGGY_WAF_TOKEN`, `SWIGGY_USE_PROXY=0` (opt out of Oxylabs; default uses it when `OXYLABS_*` is set). ## Resources - `GET /docs` — interactive Swagger UI for the same endpoints. - `GET /openapi.json` — machine-readable OpenAPI 3.1 spec. - `GET /ui/` — minimal Blinkit search UI. - `GET /ui/instamart/` — minimal Swiggy Instamart search UI. - `GET /llms.txt` — this file.