API
The same rate data as the app, over HTTP. You need one bearer key: reads are GET requests, jobs are POST requests, and every object type has a published schema.
We show a key once, when you create it, and keep only a hash of it, never the key itself. Each key has its own scopes, IP allowlist, per-minute limit and daily quota. Team and service-account keys are managed on /team.
| Key | Scopes | Allowlist | Limit / min | State |
|---|
Live API keys come with Market Analytics. Until then, you can create a sandbox key and build against synthetic data on the same endpoints.
See pricingSend the key as a bearer token. That is the whole setup.
curl "https://data.soro.health/v1/rates/compare?system=DRG&code=470" \ -H "Authorization: Bearer $SORO_API_KEY"
const res = await fetch(
"https://data.soro.health/v1/rates/compare?system=DRG&code=470",
{ headers: { Authorization: `Bearer ${process.env.SORO_API_KEY}` } },
);
const data = await res.json();
import os, requests
r = requests.get(
"https://data.soro.health/v1/rates/compare",
params={"system": "DRG", "code": "470"},
headers={"Authorization": f"Bearer {os.environ['SORO_API_KEY']}"},
)
data = r.json()
- Base URLhttps://data.soro.health/v1
- Lists{"object":"list","items":[…],"page":{…}}. Pass cursor= to fetch the next page.
- AmountsPlain USD numbers, never strings.
- Server-side onlyThe API sends no CORS headers, on purpose: a key in browser code is a leaked key.
- Every responseX-Request-Id, X-Api-Version, X-RateLimit-*.
Reads use GET and jobs use POST. Each group of endpoints needs one scope.
| Endpoint | What it returns |
|---|---|
| Rates · read:rates | |
| /v1/codes?system=CPT&family=&q= | The code table: coverage counts for every key, plus low, median and high rates for keys on Market Analytics. |
| /v1/codes/{system}/{code} | Hospital-by-hospital rows for one code, with market position and the payer-side benchmark. |
| /v1/rates?system=&code=&payer=&npi= | Provider-group rows for one code from the payers' own price files (Transparency in Coverage): payer, billing class, the group's NPIs, and the highest rate the files list. Most expensive first. The files do not record a state. |
| /v1/rates/compare?system=&code=&hospital= | The market spread for one code (the 25th percentile, median, 75th and 90th percentiles, and a histogram), or one hospital's position within it. A named hospital that prices the code as a percent of billed charges also carries pctOfCharge, explained below. |
| /v1/rates/batch?codes=&hospitals=&format= | Many codes at many hospitals in one matrix, with a row for every pairing, including those not on file. format=csv returns a downloadable CSV. Larger matrices run as jobs. |
| /v1/providers/{npi} | The provider profile, the same sections for every NPI: NPPES registration, CMS Medicare enrollment, the payers' price files (in network and out of network), Medicare Part B volume, hospital affiliations, who bills under the same tax ID, and every source checked with its date. Payer, code and rate counts cover every practice tax ID the payers list the NPI under (or, for an NPI no payer lists, every practice it is linked to), with the payers listing each tax ID. A section a key's plan does not include comes back {"locked": true}; one with nothing on file comes back null. |
| /v1/providers?q=&state= | Find a provider by name or NPI in NPPES. |
| /v1/fee-schedules?npi=|tin=|q= | One provider group's rates from the payers' own price files: an NPI or a TIN returns the group's book, page by page; a name returns NPPES matches. An NPI listed under several practice tax IDs returns the one the most payers list, with groups naming the others most payers first; pass group to open one. Dollars, and looking up by TIN, come with Market Analytics. |
| /v1/hospitals?state=&q= | The facility directory. |
| /v1/hospitals/{slug} | One facility: profile, payer coverage, contract detail. |
| /v1/benchmarks/{system}/{code} | The Medicare rate for one code, from CMS's own fee schedules. |
| Analytics · read:rates | |
| /v1/analytics/hospital-comparison?system=&code=&slugs=|health_system= | Up to 25 hospitals ranked against the market across up to 25 codes: each hospital's median against the market median for every code, the share of codes above and below it, and an average position. Keys on Market Analytics get the dollars; every key gets the on-file flags. |
| /v1/analytics/rate-distribution?system=&code= | Percentiles from the 5th to the 95th, the mean, the number of unique prices and a histogram of hospital medians for one code, with the Medicare rate and the commercial-to-Medicare ratio. |
| /v1/analytics/coverage?hospital=|payer=|state= | How much of the hospitals × codes × payers matrix we hold for one hospital, payer or state. The response's note says what that scope cannot count. |
| Payers · read:payers | |
| /v1/payers | The payer directory with coverage counts. Payer profiles and rates from insurers' Transparency in Coverage (TiC) files are served by further routes under the same prefix, listed in the live index below. |
| Jobs · write:jobs | |
| POST /v1/jobs · POST /v1/exports | Queue a batch matrix (up to 5,000 codes by 500 hospitals), an export without the 50,000-row limit, a dataset build or a delivery. GET /v1/jobs, GET /v1/jobs/{id}, POST /v1/jobs/{id}/cancel. See jobs. |
| Meta · no scope | |
| /v1 | The index: every endpoint, the rate limits, the scopes and the currency. |
| /v1/usage · /v1/audit | This key's request counts and limits, and the account's audit trail (JSON, or CSV with format=csv). |
| /v1/schema · /v1/schema/{object} · /v1/changelog | The JSON Schema for every object type, and the dated changelog. |
| /v1/status | Service health, when each dataset was last rebuilt, and any open incident. No key needed. |
The live index: every route this server answers, including those added by feature modules
- Loading GET /v1 …
Percent of charges. Some hospitals price a code as a percent of billed charges rather than as a dollar amount. Where one does, /v1/rates/compare and the MCP compare_rates tool add a pctOfCharge object beside low/median/high. pct and gross are one pair the hospital filed together on the same rows, the pair it files most often for that code. pairRows counts the rows carrying that pair, rows counts every row of the file that states a percent for the code, and pairs counts how many different pairings it files. Those counts cover every row of the hospital's file whose percent cell holds a valid percent (a number above 0 and at most 100), not a filtered subset, because they are what the pair is described against. A null gross means no dollar amount may be estimated at all, and tied above 1 means the file holds that many pairings filed on the same top number of rows, so the file does not say which pairing applies. note repeats the rule in the payload: these are filed inputs, and their product is an estimate, never a filed rate. Nothing computed from them enters a statistic, a benchmark or an export here.
For keys below Market Analytics the figures are withheld and the object carries available and estimable instead. estimable is not a figure, so every key receives it: true means the hospital files a charge on the same rows as the percent, so a dollar amount can be estimated from the pair; false means it files no charge that may be multiplied, so the percent stands alone and no plan will show an estimate. Keys on Market Analytics receive it too, as a one-boolean summary of what a null gross means. The withheld object also carries its own note saying the same thing in words, so a caller never offers its user a dollar amount that does not exist.
Answers "can you run these codes at these hospitals" in one call. You can paste codes the way they are actually written (with procedure names, parentheses, slash-paired codes, or one per line) and name hospitals the way you know them rather than the name in the file. Pass format=csv for a downloadable spreadsheet.
curl -G "https://data.soro.health/v1/rates/batch" \ --data-urlencode "codes=Septoplasty (30520), Tympanoplasty (69631), Tympanostomy tubes (69433/69436)" \ --data-urlencode "hospitals=St. Joseph's Syracuse, St. Peter's Albany" \ --data-urlencode "format=csv" \ -H "Authorization: Bearer $SORO_API_KEY" -o rates.csv
Every pairing gets a row, including the ones with no rates: on_file: no answers "does this hospital price this code at all", which is often half the question. A hospital name that matches nothing, or matches two different facilities, comes back under unmatched with the candidates named; we never substitute a match silently. Each row names its source: corpus (our aggregated multi-hospital index, which also carries the market percentile) or direct-export (taken from that hospital's own price file). A facility that appears in both sources counts as one hospital, not an ambiguity, and the entry we serve says so under alsoNamed.
Each synchronous call takes up to 25 codes and 12 hospitals. A hospital named twice uses one slot, and anything past a limit comes back by name under dropped, in the JSON and as rows in the CSV, so a call is never trimmed without saying so. For more, queue a job: up to 5,000 codes by 500 hospitals.
Authorization: Bearer sk_live_... X-Api-Key: sk_live_... # accepted as a courtesy
A revoked key stops working everywhere within a minute. If the owning account drops below Market Analytics, its keys return capped data from their next request: a key never has more access than the account that owns it. A team key carries the team's plan. A service account is a team key with no person behind it, rotated with a grace window (/team). Every key event is recorded in the audit log.
IP allowlists. A key and a whole team can each carry a list of IPv4 CIDR ranges. A call must come from an address on every list that is set, or it gets 403 ip_not_allowed. We check the address that reached our load balancer, never a header the caller wrote.
Every key has a list of scopes, chosen when you create it and editable later. A call to a path outside the key's scopes gets 403 insufficient_scope, which names the scope it needed. Read scopes are on by default; write scopes are off until you turn them on.
| Scope | Paths |
|---|---|
| Loading… | |
Any signed-in account can create an sk_test_ key. Its calls run in the sandbox: every endpoint answers from a fixed synthetic dataset (six hospitals, five payers, fourteen codes and four providers) with the same shapes, envelopes, errors and headers as production, and sandbox: true on every object. A sandbox key never reads real data, so a leaked one exposes nothing.
curl "https://data.soro.health/v1/hospitals" \
-H "Authorization: Bearer sk_test_..."
# {"sandbox":true,"note":"Sandbox data: …","object":"list","items":[{"slug":"sandbox-example-general",…}]}
Jobs run in the sandbox too, as a batch over the same synthetic data. Endpoints with no synthetic data return an empty list, still labeled as sandbox. Sandbox keys are limited to 120 requests a minute.
Limits are set per key, per minute. Each key has one budget, shared by /v1 and /mcp.
| Plan | Requests / min |
|---|---|
| Market Analytics and higher | 300 |
| Free search and Rate Exports | 60 |
| Sandbox key | 120 |
| Enterprise contract | 1,000 by default, up to 3,000 per key |
X-RateLimit-Limit: 300 X-RateLimit-Remaining: 297 X-RateLimit-Reset: 1756070460
A 429 rate_limited response carries Retry-After in seconds. A team on an enterprise contract can set a per-key limitPerMinute up to its cap, and any key can carry a dailyQuota; past it, calls get 429 quota_exceeded until midnight UTC. GET /v1/usage shows a key's counts. /mcp sends no rate-limit headers; it reports the same three numbers as rate inside every tool result.
A job asks the same question as a synchronous call, without its size limit, and writes the answer to files: you queue it, poll it and download the result. Jobs need the write:jobs scope and a key on Market Analytics (or a sandbox key).
POST /v1/jobs {"kind":"batch","spec":{"system":"CPT","codes":[…≤5000],"hospitals":[…≤500],"format":"csv|json|ndjson|parquet"}}
POST /v1/exports {"source":"provider_rates|hospital_rates|hospitals","format":"csv|ndjson|json","system":"CPT","codes":[…]|"family":"…","states":[…]}
GET /v1/jobs?status=&limit= → {"object":"list","items":[job…]}
GET /v1/jobs/{id} → {"object":"job","status":"queued|running|done|failed|cancelled","progress":0.42,
"result":{"rows":123456,"files":[{"name":"batch.csv","bytes":…,"sha256":"…","url":"https://…"}]}}
POST /v1/jobs/{id}/cancel
- 202 when queuedThe response body is the job; poll it. Once a job is done, failed or cancelled, its status never changes.
- LinksA fresh result.files[].url is issued on every read and expires after ten minutes; the stored job holds storage paths, never URLs. Use sha256 to verify the file.
- Plan at run timeWe check the owner's plan when the job runs, not when it was queued. If it has dropped below Market Analytics by then, the results come back without dollar figures.
- Kindsbatch and export run here; dataset and delivery are passed to the dataset builder and answer with its job id.
- WebhookSubscribe to job.completed on /webhooks to be notified instead of polling.
- In the appThe same queue is on /jobs.
Register an HTTPS endpoint of yours for the events you choose, on /webhooks or through /v1/webhooks with the write:webhooks scope. Every delivery is signed:
X-Soro-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, t + "." + body)> X-Soro-Event: job.completed X-Soro-Delivery-Id: dlv_…
Events: rate.changed · source.discovered · source.unavailable · payer.coverage_added · dataset.refreshed · market.movement · job.completed. We show the signing secret once, when you create the endpoint. Failed deliveries are retried with backoff, and every delivery is logged per endpoint.
Every object type /v1 returns has a JSON Schema (draft 2020-12) at GET /v1/schema/{object}; GET /v1/schema lists them. These docs, the API and our tests all read the same registry, so they cannot disagree. Pick one to read it here:
- Loading…
The major version is in the path. /v1 only ever gains fields and endpoints; anything breaking will become /v2, and /v1 will keep working. Send the dated revision you built against in a header. An unknown value gets a 400, never a silent fallback to the latest revision.
X-Api-Version: 2026-09-11 # request: pin a revision (optional) X-Api-Version: 2026-09-11 # response: the revision that answered
For at least 180 days before an endpoint is removed, it answers with Deprecation and Sunset headers (RFC 8594) and a Link rel="deprecation" pointing to the changelog. Nothing is deprecated today.
Every error has the same shape and a stable, machine-readable code:
{
"error": {
"code": "rate_limited",
"message": "Rate limit reached for this key.",
"hint": "Limit is 300 requests/min…"
},
"request_id": "req_6a53f9eb04ab543e"
}
Codes: invalid_request · unauthorized · permission_denied · insufficient_scope · ip_not_allowed · not_found · rate_limited · quota_exceeded · internal. Quote the request_id when you contact us.
- StatusGET /v1/status reports service health and dataset build dates, with no key needed.
- File datesEach hospital row carries reportedOn, the date the hospital's own file states.
The same data as a set of read-only tools that an AI client can call itself, over one endpoint. It takes the keys above and spends the same per-key budget. The server URL, the tool list and setup for Claude Code, Codex, Cursor, VS Code and Claude Desktop are on the MCP page.
Every dated revision, newest first. GET /v1/changelog returns the same list.
Loading…