← BhavRadar

Desk API v1

The same NSE disclosed-deal data the terminal runs on, as JSON over HTTPS. Read-only, keyed, metered — plus signed webhooks that push events to your own server as they land.

On this page
  1. Getting a key
  2. Authentication
  3. Limits & errors
  4. The honesty contract
  5. Endpoints
  6. Pagination
  7. Webhooks
  8. Fair use

1 · Getting a key

Keys are issued by the bot, not by a form. Open @smrprobot on Telegram and send:

/apikey new laptop

You get back a key that looks like bhv_live_…. It is shown exactly once. We store only its SHA-256 hash, so we cannot show it to you again and we cannot read it back if you lose it — mint a new one instead.

CommandWhat it does
/apikeyList your keys by label. Never shows a key.
/apikey new <label>Mint a key. Up to 5 live at once; labels are unique.
/apikey revoke <label>Kill it. Effective on the very next request, not eventually.

2 · Authentication

Send the key as a bearer token. An X-API-Key header works too.

curl -s https://bhavradar.com/api/v1/deals?limit=3 \
  -H "Authorization: Bearer bhv_live_xxxxxxxxxxxxxxxxxxxxxxxx"

Keys go in headers, never in a query string — a URL ends up in proxy logs, browser history and referrer headers. HTTPS only; there is no plain-http listener to fall back to.

3 · Limits & errors

LimitValueOn exceeding
Requests per minute60 per key, rolling window429 rate_limited
Requests per day20,000 per key, IST calendar day429 quota_exceeded
Rows per page500 maximumsilently capped at 500
Live keys5 per accountbot refuses the 6th

Every response — success and error — is a JSON object carrying ok and disclaimer. Errors add error (a stable machine code) and message (for a human).

{
  "ok": false,
  "error": "rate_limited",
  "message": "Rate limit: 60 requests per minute.",
  "disclaimer": "Historical facts, not a rating. Analytics, not investment advice."
}
CodeHTTPMeaning
unauthorized401Missing, malformed or revoked key.
rate_limited429Over 60/min. Back off and retry.
quota_exceeded429Over 20k today. Resets at IST midnight.
not_found200No such investor on record.
bad_request200A parameter did not make sense.

Check ok, not just the status code.

4 · The honesty contract

This is the part worth reading before you build anything.

Every investor record carries a record object, and that object carries its own caveats as fields rather than as prose you could strip off:

"record": {
  "measurable": true,
  "min_events": 8,
  "n": 30,                       // measured buys
  "clusters": 13,                // INDEPENDENT buy windows — the real sample
  "coverage": 0.75,              // share of the disclosed book we could measure
  "beat_median_stock_pct": 70,
  "median_excess_pct": 3.4,
  "horizon_sessions": 20,
  "benchmark": "median universe stock over the same window"
}

When measurable is false, every rate is null

Not zero, not a number with a footnote — null. Below min_events independent buy windows there is no percentage in the payload at all, so a client cannot render a track record we did not earn. The null is the mechanism. Branch on measurable and show your users the n beside anything you do print.

Two more things the numbers mean, exactly:

coverage tells you how much of an investor's disclosed book we could actually measure — a 0.4 coverage means most of it is unpriced, and the record describes the part we could follow. Show it.

5 · Endpoints

Base https://bhavradar.com/api/v1 · all GET · all read-only.

GET/deals

The disclosed bulk and block deal tape. NSE publishes these after the close, so the most recent session appears in the evening — a live named-counterparty tape does not exist at any price.

ParamMeaning
symbolExact NSE symbol, e.g. TATAMOTORS.
investorSubstring match on the disclosed client name.
sideBUY or SELL.
since, untilYYYY-MM-DD, inclusive.
limitDefault 200, max 500.
cursorSee pagination.
{
  "ok": true,
  "deals": [
    { "id": 48211, "date": "2026-07-31", "kind": "bulk", "symbol": "ACME",
      "investor": "BIG FUND LLP", "side": "BUY",
      "quantity": 100000, "price": 300.0, "value_cr": 3.0 }
  ],
  "cursor": 48211,
  "has_more": true,
  "disclaimer": "…"
}

GET/whales

Every investor on record, biggest disclosed book first. Params: min_deals, active_since (YYYY-MM-DD), limit, cursor. Each item is a full record — read §4 first.

GET/whales/{slug}

One investor, by slug or by name. Returns not_found if we have nothing disclosed for them.

GET/stocks/{symbol}

One stock's disclosed history: the deal tape, its top disclosed buyers, and disclosed_total — the untruncated count, so you can tell a short tape from a truncated one.

GET/scan

The current scan, with each name's evidence lines and the points each line contributed.

{ "symbol": "ACME", "score": 80, "badges": ["WHALE BUY"],
  "price": 412.5, "change_pct": 2.0,
  "evidence": [ { "points": 9, "text": "Delivery 71% vs 44% average" } ] }

Scores are descriptions, not ratings

A score is this scanner's own composite of disclosed footprints — delivery, volume, deal flow, relative strength. It ranks what is visible on a name today. It is not a rating, a target, or a forecast, and the payload says so in its note field. Please keep that meaning intact in whatever you build.

Also carries market_open, session_of_record and as_of — stamp your UI with them rather than with your own clock.

GET/events

Known corporate event dates for names in the universe. Exchange-published dates do shift; treat them as scheduled, not settled.

GET/consensus

Names where two or more counterparties that carried their disclosed positions — market makers excluded — bought inside the trailing 30 sessions. Each buyer ships with its own record under the same null-when-unmeasurable contract, and the response carries the pattern's own measured history:

"pattern_record": {
  "measurable": true,
  "n": …,                          // events measured
  "clusters": …,                   // DISTINCT event dates — the real sample
  "beat_median_stock_pct": …, "ci_low": …, "ci_high": …,
  "median_excess_pct": …,
  "single_buyer_baseline_pct": …,  // the control group, in the same payload
  "horizon_sessions": 20,
  "entry_basis": "close of the session AFTER the last qualifying disclosure",
  "benchmark": "median universe stock over the same window"
}

When measurable is false every rate is null — same contract as investor records. The single-buyer baseline ships beside the claim so you can always see the control group it beat.

GET/usage

Your key's own meter: requests_today, remaining_today, daily_limit, per_minute_limit. Cheap — poll it instead of guessing.

6 · Pagination

Paging is keyset, not offset. Pass the cursor from a response back as the next request's cursor. Page 400 costs what page 1 costs.

cursor = None
while True:
    q = {"limit": 500, "symbol": "ACME"}
    if cursor: q["cursor"] = cursor
    r = get("/api/v1/deals", params=q)
    yield from r["deals"]
    if not r["has_more"]: break
    cursor = r["cursor"]

There is no offset parameter and there will not be one — at 60 requests a minute an offset scan over a growing tape degrades with depth and takes the database down with it.

7 · Webhooks

Rather than polling /deals every minute, have us POST to you when something lands. Register in the bot:

/hooks add https://your.server/bhavradar deal.recorded,alert.fired

Omit the event list for everything. You get a signing secret (whsec_…) shown once. Up to 3 live hooks; /hooks lists them with their failure counts, /hooks remove <id> stops one.

EventFires when
deal.recordedNew disclosed bulk/block deals are ingested (evening, after NSE publishes).
alert.firedA stock alert goes out, with its score, badges and lead line.
whale.scorecard_updatedAn investor's measured record changes.
report.publishedA morning brief or evening wrap is published.

Every POST has Content-Type: application/json and this body:

{
  "event": "deal.recorded",
  "ts": "2026-07-31T19:42:11+05:30",
  "seq": 184,
  "data": { … }
}

seq counts up per hook and never repeats — a gap means you missed one, which is something polling can never tell you.

Verifying the signature

Each request carries X-BhavRadar-Signature: sha256=<hex>, an HMAC-SHA256 of the raw request body using your secret. Verify it against the bytes you received, before parsing — a re-serialised body will not match, and it should not.

# Python / Flask
import hmac, hashlib

@app.post("/bhavradar")
def hook():
    raw = request.get_data()                       # RAW bytes, not request.json
    want = "sha256=" + hmac.new(SECRET.encode(), raw, hashlib.sha256).hexdigest()
    got  = request.headers.get("X-BhavRadar-Signature", "")
    if not hmac.compare_digest(want, got):         # constant time, not ==
        return "", 401
    handle(json.loads(raw))
    return "", 200                                 # any 2xx means delivered
// Node / Express — express.raw({type: "application/json"})
const want = "sha256=" + crypto.createHmac("sha256", SECRET)
                               .update(req.body).digest("hex");
const got  = req.get("X-BhavRadar-Signature") || "";
const a = Buffer.from(want), b = Buffer.from(got);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.sendStatus(401);

Delivery behaviour

RuleDetail
SuccessAny 2xx, within 5 seconds.
RetriesAfter 1m, 5m, 30m, 2h. Byte-identical body and signature each time.
Giving upDead-lettered and counted — visible in /hooks, never silently dropped.
RemovalTakes effect mid-ladder: a removed hook stops being retried.
OrderingDeliveries can arrive out of order. Sequence them on seq.
DuplicatesPossible if your 2xx is lost. Make your handler idempotent on seq.

HTTPS to a public host, and nothing else

We refuse plain http, URLs with credentials in them, and any host that resolves to a loopback, private, link-local or reserved address — checked at registration and again at every delivery, because DNS can change in between. If your endpoint is behind a tunnel or on a private network, publish it properly first.

8 · Fair use

There is no execution surface here and there never will be: no order routing, no broker links, nothing execution-adjacent. This is an observational dataset.

Questions, a bug, or a use case that needs a higher limit — message @smrprobot.