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.
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.
| Command | What it does |
|---|---|
/apikey | List 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. |
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.
| Limit | Value | On exceeding |
|---|---|---|
| Requests per minute | 60 per key, rolling window | 429 rate_limited |
| Requests per day | 20,000 per key, IST calendar day | 429 quota_exceeded |
| Rows per page | 500 maximum | silently capped at 500 |
| Live keys | 5 per account | bot 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."
}
| Code | HTTP | Meaning |
|---|---|---|
unauthorized | 401 | Missing, malformed or revoked key. |
rate_limited | 429 | Over 60/min. Back off and retry. |
quota_exceeded | 429 | Over 20k today. Resets at IST midnight. |
not_found | 200 | No such investor on record. |
bad_request | 200 | A parameter did not make sense. |
Check ok, not just the status code.
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"
}
measurable is false, every rate is nullNot 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:
clusters, not n, is the sample size.
Ten names bought on one day is one decision, not ten. Rates are computed and
bounded on independent windows.median_excess_pct is what was
left after the market's own move over that exact window.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.
Base https://bhavradar.com/api/v1 · all GET ·
all read-only.
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.
| Param | Meaning |
|---|---|
symbol | Exact NSE symbol, e.g. TATAMOTORS. |
investor | Substring match on the disclosed client name. |
side | BUY or SELL. |
since, until | YYYY-MM-DD, inclusive. |
limit | Default 200, max 500. |
cursor | See 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": "…"
}
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.
One investor, by slug or by name. Returns not_found if we have
nothing disclosed for them.
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.
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" } ] }
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.
Known corporate event dates for names in the universe. Exchange-published dates do shift; treat them as scheduled, not settled.
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.
Your key's own meter: requests_today,
remaining_today, daily_limit,
per_minute_limit. Cheap — poll it instead of guessing.
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.
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.
| Event | Fires when |
|---|---|
deal.recorded | New disclosed bulk/block deals are ingested (evening, after NSE publishes). |
alert.fired | A stock alert goes out, with its score, badges and lead line. |
whale.scorecard_updated | An investor's measured record changes. |
report.published | A 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.
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);
| Rule | Detail |
|---|---|
| Success | Any 2xx, within 5 seconds. |
| Retries | After 1m, 5m, 30m, 2h. Byte-identical body and signature each time. |
| Giving up | Dead-lettered and counted — visible in /hooks, never silently dropped. |
| Removal | Takes effect mid-ladder: a removed hook stops being retried. |
| Ordering | Deliveries can arrive out of order. Sequence them on seq. |
| Duplicates | Possible if your 2xx is lost. Make your handler idempotent on seq. |
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.
n and
clusters is a different claim from the one we made.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.