# API reference

> The PatternsRadar REST API at https://api.patternsradar.com — market data, screening, saved scans, alerts and account management. Authenticate with `Authorization: Bearer prdr_YOUR_KEY`; keys are free on every plan.

Canonical: https://patternsradar.com/docs/api

Plain JSON over HTTPS; everything lives under `/v1`. Every response carries `X-Request-ID`. Market responses are snake_case; the scan envelope is camelCase with snake_case rows.

## Authentication

- Keys are prefixed `prdr_` and go in `Authorization: Bearer prdr_YOUR_KEY`. They are free on every plan, including Free.
- A key is shown once, at creation; only a hash is stored. Check one with `GET /v1/auth/me`.
- The screening endpoints work anonymously — a key simply applies your plan's rate limit instead of the shared per-IP one.
- MCP is the exception: it requires a key, and answers `401` without one.
- Signing in, managing keys and billing are deliberately browser-only and have no API.

## Rate limits

| Plan | API req/min | Scans/min | Saved scans | Alert scans | Webhooks | API keys |
|---|---|---|---|---|---|---|
| free | 60 | 20 | 5 | — | — | 2 |
| pro | 300 | 100 | 100 | 50 | — | 5 |
| max | 600 | 240 | 500 | 200 | yes | 10 |

- Two separate buckets per identity: scans and hit-rate replays are metered apart from ordinary lookups, so exhausting one does not lock you out of the other.
- Limits smooth over the minute rather than resetting on a boundary.
- Requests count against your API key when one is sent, otherwise against your IP.
- A `429` carries `Retry-After` with the seconds to wait.

## Errors

Failures are RFC 9457 problem documents, served as `application/problem+json`. Every one is the same seven members — `type`, `title`, `status`, `detail`, `instance`, `code`, `request_id` — whatever the status, so one parser reads all of them. A plan limit is marked by a `code` beginning `limit_`; a `429` carries its wait in the `Retry-After` header. The exception: `POST /v1/scan` and `POST /v1/hitrate` answer `200` with `ok: false` for a query the compiler refuses — a syntax error is a result, not a server failure.

## Market data

Symbols, bars, stats and search. All of it is public — these answer without credentials, straight from the market database.

### GET /v1/meta

Database coverage. What the market database covers: first and last session, row counts, and how many symbols. Call it to find out how fresh the data is.

Auth: none.

### GET /v1/search

Symbol search. Find NSE symbols by ticker or company name, whole or partial.

Auth: none.

Query parameters:

- `q` (string, required): Ticker or company name, whole or partial.
- `limit` (number, default 12, clamped 1..50): Maximum hits to return.

### GET /v1/symbols

All symbols. Every ticker in the database, as a flat list. Meant for sitemaps and bulk jobs; it changes only when the data is rebuilt.

Auth: none.

### GET /v1/symbols/:symbol

Symbol identity. One symbol's identity: name, ISIN, listing dates, bar count, liquidity rank and tier.

Auth: none.

Path parameters:

- `symbol` (string, required): The NSE ticker.

### GET /v1/symbols/:symbol/stats

Symbol stats. The latest session's derived numbers for one symbol — returns, 52-week range, RSI, moving averages, delivery — plus an average delivery percentage over a chosen window.

Auth: none.

Path parameters:

- `symbol` (string, required): The NSE ticker.

Query parameters:

- `delivery_sessions` (number, default 20, clamped 1..5000): Sessions to average `delivery_pct` over.

### GET /v1/symbols/:symbol/bars

OHLCV bars. The full split-adjusted history for one symbol, oldest first — decades of it for an old listing.

Auth: none.

Path parameters:

- `symbol` (string, required): The NSE ticker.

Query parameters:

- `timeframe` ("1d" | "1w" | "1mo", default "1d"): Bar size. `daily`, `weekly` and `monthly` are accepted as synonyms; anything else is a 400.

## Screening

The heart of the API: compile and run Sift queries. Both endpoints answer anonymous callers — the public screener runs on them — but a key applies your plan's rate limit instead of the shared per-IP one.

### POST /v1/scan

Run a scan. Compile a Sift query and run it against a session. The source is exactly what you would type into the editor; the reference for the language lives at /docs/sift.

Auth: optional-key.

Body:

- `source` (string, required): The Sift query. At most 8,000 characters.
- `universe` (number, default 500, clamped 100..9999): The most liquid N equities by 20-day turnover. `9999` means all equities.
- `asOf` (string): Run as of this session, `YYYY-MM-DD`. Clamped to the database's range; defaults to the latest session. Scanning a past day costs the same as today.
- `limit` (number, default clamped 1..2000): Maximum rows returned.
- `orderBy` (string): Result column to sort by, e.g. `turnover` or `rsi_14`. The query's own `sort by` clause wins if both are given.
- `orderDir` ("asc" | "desc"): Sort direction.
- `kinds` (string[]): Instrument kinds to include, e.g. `["equity"]`.

### POST /v1/hitrate

Hit rate. Replay a query across past sessions and report what its matches did over configurable forward horizons: win rate, payoff, excess return against an index, a monthly breakdown and an equity-curve approximation. A sketch, not a backtest: no costs or slippage, universe membership measured as of today — everything here describes past matches, never a prediction.

Auth: optional-key.

Body:

- `source` (string, required): The Sift query to replay. At most 8,000 characters.
- `universe` (number, default 500, clamped 100..9999): Liquidity universe.
- `sessions` (number, default 250, clamped 10..7500 — the whole history): How many past sessions to replay.
- `horizons` (number[], default [1, 5, 20]): Forward windows in trading days, each clamped 1..60, at most 4 distinct values. Deduplicated and sorted ascending.
- `entryMode` (string, default "close"): Where the position is priced: `close` (the signal bar's own close) or `nextOpen` (the next session's open — the earliest fill a reader of an EOD scan could take). Both exit at the close of bar signal+h.
- `benchmark` (string, default "NIFTY 50"): Index for excess-return measurement: `NIFTY 50`, `NIFTY 100`, `NIFTY 200`, `NIFTY 500`, `NIFTY BANK`, `NIFTY MIDCAP 150`, `NIFTY SMALLCAP 250`, or `none` to skip it.
- `detailHorizon` (number, default the largest horizon): Which horizon the `monthly`, `histogram` and `equityCurve` detail describes. Must be one of `horizons`.
- `stopLossPct` (number, default none): Simulate a stop-loss this many percent below entry (clamped 0.1..95). Setting this or `targetPct` switches the replay into a trade simulation: each signal walks its forward bars and exits at the stop, the target, or the horizon close — whichever comes first. When one bar's range covers both levels, the stop wins; that is the conservative reading, stated rather than hidden.
- `targetPct` (number, default none): Simulate a profit target this many percent above entry (clamped 0.1..95). Fills at the target price, no slippage.

## Saved scans

The scans on your account — the same library the screener's sidebar shows, readable and writable with your key.

### GET /v1/scans

List saved scans. Every scan saved to the account.

Auth: key.

### POST /v1/scans

Save a scan. Save a query under a name. Saving over an existing name revises that scan rather than creating a second one — an upsert, so revising never hits the quota.

Auth: key.

Body:

- `name` (string, required): What to call it. At most 120 characters.
- `source` (string, required): The Sift query. At most 8,000 characters.
- `universe` (number, default 500): Liquidity universe the scan runs against.

### PATCH /v1/scans/:id

Rename a scan. Change a saved scan's name.

Auth: key.

Path parameters:

- `id` (number, required): The scan's id, from the list.

Body:

- `name` (string, required): The new name. Non-empty, at most 120 characters.

### DELETE /v1/scans/:id

Delete a scan. Delete a saved scan. Its alert, if any, goes with it.

Auth: key.

Path parameters:

- `id` (number, required): The scan's id.

## Alerts

Run a saved scan automatically after each session and get told what matched — by email, or on Max by webhook too. Alerts need a paid plan.

### GET /v1/alerts

List alerts. Every alert on the account, with the plan's alert allowances alongside.

Auth: key.

### GET /v1/alerts/events

Alert history. What the nightly evaluation found, newest first — including quiet nights where nothing fired.

Auth: key.

Query parameters:

- `scan_id` (number): Only this scan's events. Omit for all scans.
- `limit` (number): Maximum events to return.

### GET /v1/alerts/:id/deliveries

Webhook deliveries. The delivery log for one alert's webhook: every attempt, its status code and how long it took.

Auth: key.

Path parameters:

- `id` (number, required): The saved scan's id the alert belongs to — there is no separate alert id.

Query parameters:

- `limit` (number): Maximum rows to return.

### PUT /v1/scans/:id/alert

Create or update an alert. Attach an alert to a saved scan, or change one that exists. A full replace: send the whole configuration each time.

Auth: key.

Path parameters:

- `id` (number, required): The saved scan's id.

Body:

- `enabled` (boolean, default true): Whether the alert runs at all.
- `trigger_mode` ("new_matches" | "any_match", required): `new_matches` fires only when a symbol enters the result set; `any_match` fires whenever the scan matches anything.
- `channel_email` (boolean, default true): Send matches by email.
- `webhook_url` (string): HTTPS URL to POST matches to. Max plan only. Validated against private-address targets when saved.

### DELETE /v1/scans/:id/alert

Remove an alert. Detach the alert from a scan. The scan itself stays.

Auth: key.

Path parameters:

- `id` (number, required): The saved scan's id.

## Account

The account your key belongs to: who it is, what plan it is on, and whether alert emails are sent. Accounts themselves — signup, plans, keys — are managed in the browser.

### GET /v1/auth/me

Who am I. The authenticated account, its plan, the plan's limits and current usage — the call to make first when checking a key works.

Auth: key.

### GET /v1/me/notifications

Notification preferences. Whether alert emails are on for the account.

Auth: key.

### PATCH /v1/me/notifications

Update notification preferences. Turn alert emails on or off account-wide. The unsubscribe link in every alert email flips the same switch, authenticated by its own signed token.

Auth: key.

Body:

- `email_alerts_enabled` (boolean, required): Whether alert emails are sent at all.

## Webhooks

- Max plan only. Fires when a watched scan matches, after the close.
- Headers: `X-PatternsRadar-Event`, `X-PatternsRadar-Delivery`, `X-PatternsRadar-Timestamp`, `X-PatternsRadar-Signature`.
- Signature: `sha256=hex(hmac_sha256(secret, timestamp + "." + body))`. Reject timestamps more than five minutes old.
- The sender waits at most ten seconds and follows no redirects. A failed delivery is retried twice — about ten seconds later, then about two minutes later.
- Deduplicate on `X-PatternsRadar-Delivery`; a retry reuses the same id.
- A URL that keeps failing for days is marked `webhook_disabled`. The secret never rotates on its own.

## Error catalog

Every problem document's `type` URI is `https://patternsradar.com/docs/api/errors#<code>`.

| Status | Code | Title | When |
|---|---|---|---|
| 400 | `validation` | your request is invalid | The request itself is malformed — a body that is not JSON, a missing field, an over-long query, an id that is not a number. `detail` names what was wrong. |
| 401 | `unauthorized` | authentication required | No credential where one was needed: no session cookie, or no bearer key on a route that requires one. |
| 401 | `session_expired` | your session has expired | A session cookie was sent but no longer matches a live session — it expired or was revoked. Sign in again. |
| 401 | `invalid_api_key` | that API key is not valid | A bearer key was sent but does not resolve to an active key — mistyped or revoked. The response carries a `WWW-Authenticate` header with `error="invalid_token"`. |
| 401 | `invalid_credentials` | email or password is incorrect | Login refused. One message for an unknown address and a wrong password, deliberately, so the endpoint cannot be used to discover which addresses are registered. |
| 403 | `forbidden` | not allowed | Authenticated, but this account may not do that. The generic case; the plan limits below are the specific ones. |
| 403 | `session_only` | API keys cannot use this endpoint | The browser-only surface — key management, billing, signing out everywhere — refuses API keys: a leaked key must not be able to mint replacements for itself or touch billing. Sign in with a browser session instead. |
| 403 | `email_unverified` | confirm your email address first | The account exists and is signed in, but its email address has never been confirmed. Two things need a confirmed address — switching on an alert, because we would be mailing an unproven address, and publishing a scan, because it becomes a public page. Everything else on the account works. `POST /v1/auth/email/resend` sends another link. This is not a plan limit and no upgrade clears it. |
| 400 | `invalid_token` | that link is not valid | A signed link — an email confirmation, a password reset — did not verify. Either it was altered in transit, it was truncated by a mail client that wrapped the URL, or it has already been used. Ask for a new one. |
| 410 | `expired_token` | that link has expired | The link verified but is past its lifetime: one hour for a password reset, three days for an email confirmation. Distinct from `invalid_token` on purpose, so the page can offer to send another rather than implying the link was wrong. |
| 403 | `limit_saved_scans` | saved-scan limit reached | The account already keeps as many scans as the plan allows. Existing scans stay readable, runnable and deletable. |
| 403 | `limit_alerts_unavailable` | alerts are not available on this plan | Alerting is part of the paid plans. |
| 403 | `limit_alert_scans` | alerted-scan limit reached | As many scans are already alerting as the plan allows. |
| 403 | `limit_webhooks` | webhook delivery is not available on this plan | Webhook delivery is part of the Max plan. |
| 403 | `limit_api_keys` | API key limit reached | The account holds as many active keys as the plan allows. Revoke one to make room. |
| 403 | `limit_mcp` | the MCP server is not available on this plan | The plan does not include Model Context Protocol access. Every current plan does; a plan can be configured without it. |
| 403 | `limit_rest_api` | API access is not available on this plan | The plan does not include calling the API with a bearer key. Only key-authenticated requests are refused — the app itself keeps working, because it signs in with a session. |
| 403 | `limit_scans_per_day` | today's scan allowance is spent | The account has run as many scans today as the plan allows. Distinct from `rate_limited`, which is a 429 meaning "slow down and try again in a moment": this is the day's budget, and the fix is a different plan or tomorrow, not a retry. Anonymous callers never see it — they have no plan and no daily allowance, only the per-minute limit on their address. |
| 403 | `limit_hitrate_per_day` | today's replay allowance is spent | The account has run as many hit-rate replays today as the plan allows. Its own allowance because a replay costs several times what a scan does, so spending one budget does not spend the other. |
| 403 | `scan_taken_down` | that scan has been taken down | A published scan an operator has withdrawn. Not a plan limit — no upgrade lifts it, so the client must not offer one — and the `detail` carries the reason, which is meant to be shown to the author. |
| 400 | `invalid_coupon` | that coupon code is not valid | No such code, or it does not apply to the plan being bought, or it has been fully redeemed, or this account has already used it. The detail says which. |
| 410 | `coupon_expired` | that coupon has expired | The code was real and its window has closed. Distinct from invalid_coupon because there is nothing the customer can do about it. |
| 409 | `plan_archived` | that plan is no longer available | The plan has been withdrawn from sale. Accounts already on it keep it and keep their limits; it simply takes no new subscribers. |
| 404 | `not_found` | not found | No such route, symbol, scan, alert or key. Ownership is not disclosed: something that exists but belongs to somebody else answers the same way. |
| 409 | `conflict` | conflict | The request collides with current state — a taken email, a duplicate scan name, a subscription already on that plan, nothing active to cancel. |
| 429 | `rate_limited` | too many requests | Over the per-minute allowance. The body is the plain problem document; the wait is in the `Retry-After` header, and the numbers per plan are in the rate-limits section. |
| 500 | `internal` | the request could not be completed | Something went wrong on our side. The response says no more than that; quote the `request_id` when reporting it, and the request may be retried. |
| 502 | `upstream` | an upstream provider failed | A provider the request depends on did not answer usefully. Safe to retry; if it persists, wait a little and try again. |
| 503 | `billing_unconfigured` | billing is not configured on this deployment | Subscription routes are present but cannot act on this deployment. |

Worked examples in six languages live on the rendered page: https://patternsradar.com/docs/api. The same capabilities over MCP: https://patternsradar.com/docs/mcp.md.
