Wage Data API
JSON wage percentiles (p10 to p90) for U.S. employers, computed from certified H-1B Labor Condition Application (LCA) filings published by the U.S. Department of Labor, next to BLS Occupational Employment and Wage Statistics (OEWS) benchmarks. Every response carries its sources.
Quickstart
Base URL: https://data-api.premed-toolkit.workers.dev. Send your key as X-API-Key: <key> or Authorization: Bearer <key>. The free tier is 100 successful calls per month at 10 per minute. /v1/health, /v1/openapi.json and this page need no key.
Get a free key
Enter your email, click the confirmation link we send, and your key (100 calls per month) arrives by email within about 15 minutes. One key per address.
curl
export WAGE_API_KEY="dak_your_key_here"
curl -s -H "X-API-Key: $WAGE_API_KEY" "https://data-api.premed-toolkit.workers.dev/v1/wages/employer/amazon"
curl -s -H "X-API-Key: $WAGE_API_KEY" "https://data-api.premed-toolkit.workers.dev/v1/wages/occupation/15-1252?metro=san-jose"
curl -s -H "X-API-Key: $WAGE_API_KEY" "https://data-api.premed-toolkit.workers.dev/v1/wages/occupation?title=software%20engineer&state=CA"
curl -s -H "X-API-Key: $WAGE_API_KEY" "https://data-api.premed-toolkit.workers.dev/v1/usage"
Python
import json, os, urllib.parse, urllib.request
BASE = "https://data-api.premed-toolkit.workers.dev"
KEY = os.environ["WAGE_API_KEY"] # never hard-code a key
def get(path, **params):
url = BASE + path + ("?" + urllib.parse.urlencode(params) if params else "")
req = urllib.request.Request(url, headers={"X-API-Key": KEY})
with urllib.request.urlopen(req, timeout=20) as r:
return json.load(r)
emp = get("/v1/wages/employer/amazon")
print(emp["data"]["wage_percentiles_usd"], emp["data"]["median_vs_oews_ratio"])
occ = get("/v1/wages/occupation", title="software engineer", state="CA")
print(occ["data"]["occupation"], occ["data"]["p50"], occ["data"]["area_level"]) # p50 can be None when BLS suppresses it
JavaScript (Node 18+, server side)
const BASE = "https://data-api.premed-toolkit.workers.dev";
const KEY = process.env.WAGE_API_KEY; // never ship a key in browser code
async function get(path, params = {}) {
const qs = new URLSearchParams(params).toString();
const res = await fetch(BASE + path + (qs ? "?" + qs : ""), { headers: { "X-API-Key": KEY } });
const body = await res.json();
if (!res.ok) throw new Error(body.error?.code + ": " + body.error?.message);
return body;
}
const emp = await get("/v1/wages/employer/amazon");
console.log(emp.data.wage_percentiles_usd, emp.data.median_vs_oews_ratio);
Google Sheets (Apps Script)
Extensions, Apps Script, paste, then Project Settings, Script properties, add WAGE_API_KEY. Use =WAGE_MEDIAN("software engineer", "CA") in a cell. Results are cached for six hours so a sheet does not burn quota.
const BASE = "https://data-api.premed-toolkit.workers.dev";
/** Median annual wage (BLS OEWS) for a job title in a two-letter state. */
function WAGE_MEDIAN(title, state) {
const key = PropertiesService.getScriptProperties().getProperty("WAGE_API_KEY");
if (!key) throw new Error("Set the WAGE_API_KEY script property");
const url = BASE + "/v1/wages/occupation?title=" + encodeURIComponent(title) +
(state ? "&state=" + encodeURIComponent(state) : "");
const cache = CacheService.getScriptCache(), ck = Utilities.base64EncodeWebSafe(url).slice(0, 200);
const hit = cache.get(ck);
if (hit) return Number(hit);
const res = UrlFetchApp.fetch(url, { headers: { "X-API-Key": key }, muteHttpExceptions: true });
const body = JSON.parse(res.getContentText());
if (res.getResponseCode() !== 200) throw new Error(body.error ? body.error.code : "HTTP " + res.getResponseCode());
if (body.data.p50 === null) throw new Error("No benchmark (suppressed or top coded)");
cache.put(ck, String(body.data.p50), 21600);
return body.data.p50;
}
Endpoint reference
Generated from the live OpenAPI document at /v1/openapi.json. Success bodies are {data, attribution, meta}. Only 2xx GET responses count toward quota; 4xx and 5xx are refunded and HEAD is never metered. Headers: x-ratelimit-*, x-quota-*, retry-after.
GET /v1/wages/employer/{slug}
H-1B/LCA wage percentiles for one employer
| Parameter | In | Type | Notes |
|---|---|---|---|
slug | path, required | string | Example: amazon |
Responses:
200OK400bad_encoding or bad_slug401Missing or invalid API key403tier_unknown, endpoint_not_in_plan (extension plan), pro_required or pro_unavailable (extension plan employer data)404Unknown employer405Only GET, HEAD and OPTIONS are supported429rate_limited (per minute, per key or per IP) or quota_exceeded (monthly). See Retry-After.500internal: unexpected error. Not billed; the quota unit is refunded.
GET /v1/wages/occupation/{soc}
Occupation wages by metro (LCA vs OEWS)
| Parameter | In | Type | Notes |
|---|---|---|---|
soc | path, required | string | Example: 15-1252 |
metro | query | string | Metro slug, MSA code or name fragment. Omit to list metros. Example: san-jose |
limit | query | integer | Metros listed when metro is omitted. |
Responses:
200OK400bad_soc or bad_metro401Missing or invalid API key403tier_unknown, endpoint_not_in_plan (extension plan), pro_required or pro_unavailable (extension plan employer data)404No data405Only GET, HEAD and OPTIONS are supported429rate_limited (per minute, per key or per IP) or quota_exceeded (monthly). See Retry-After.500internal: unexpected error. Not billed; the quota unit is refunded.
GET /v1/wages/search
Search employers and occupations
| Parameter | In | Type | Notes |
|---|---|---|---|
q | query, required | string | Example: google |
type | query | string (employer | occupation) | |
limit | query | integer | Capped per plan (10 to 50). |
Responses:
200OK400bad_query or bad_type401Missing or invalid API key403tier_unknown, endpoint_not_in_plan (extension plan), pro_required or pro_unavailable (extension plan employer data)405Only GET, HEAD and OPTIONS are supported429rate_limited (per minute, per key or per IP) or quota_exceeded (monthly). See Retry-After.500internal: unexpected error. Not billed; the quota unit is refunded.
GET /v1/wages/occupation
Free-text job title to BLS OEWS percentiles (metro, then state, then national) The title is matched to an SOC code against every OEWS SOC title and O*NET alternate titles, with a score. Below 0.6 the API returns 404 no_match with suggestions instead of guessing. A city is used only when it names a principal city of a metro in the requested state; otherwise the real BLS state estimate (or national estimate) is returned.
| Parameter | In | Type | Notes |
|---|---|---|---|
title | query, required | string | Example: software engineer |
level | query | string | Accepted for client compatibility; not used. |
state | query | string | Two-letter U.S. state code (50 states, DC, PR, GU, VI). Anything else is 400 bad_state. Example: TX |
city | query | string | Whole city name; fewer than 3 characters is ignored. Example: Austin |
Responses:
200OK400bad_query, title_too_long, bad_state or bad_city401Missing or invalid API key403tier_unknown, endpoint_not_in_plan (extension plan), pro_required or pro_unavailable (extension plan employer data)404no_match: no confident occupation match405Only GET, HEAD and OPTIONS are supported429rate_limited (per minute, per key or per IP) or quota_exceeded (monthly). See Retry-After.500internal: unexpected error. Not billed; the quota unit is refunded.
GET /v1/wages/employer
Employer wage percentiles by name, optional job title Extension plan: needs X-License-Key with a valid RangeCheck Pro license (403 pro_required otherwise; 503 license_check_unavailable when Polar cannot be reached).
| Parameter | In | Type | Notes |
|---|---|---|---|
name | query, required | string | Example: Amazon |
title | query | string | With title, p25/p50/p75/filings are for that employer's matching occupation and p10/p90 are null. Example: software |
X-License-Key | header | string | Extension plan only. |
Responses:
200OK400bad_query or title_too_long401Missing or invalid API key403tier_unknown, endpoint_not_in_plan (extension plan), pro_required or pro_unavailable (extension plan employer data)404No filings, or no filings matching title405Only GET, HEAD and OPTIONS are supported429rate_limited (per minute, per key or per IP) or quota_exceeded (monthly). See Retry-After.500internal: unexpected error. Not billed; the quota unit is refunded.503license_check_unavailable (extension plan)
GET /v1/usage
Current month usage and limits for your key (not metered)
Responses:
200OK401Missing or invalid API key403tier_unknown, endpoint_not_in_plan (extension plan), pro_required or pro_unavailable (extension plan employer data)429rate_limited (per minute, per key or per IP) or quota_exceeded (monthly). See Retry-After.
GET /v1/health
Liveness and dataset versions (no key needed)
Responses:
200OK
Coverage, read live
Right now the API serves 500 employers and 300 occupation by metro pairs with LCA percentiles, plus BLS OEWS May 2025 percentiles for 830 occupations nationally, by state and by metro area (139,505 metro estimates). Employer data last loaded 2026-10-21.
These numbers are read from the same store that answers your calls (see /v1/health). An employer outside the set returns 404 not_found, which is free. The API loads every employer and every occupation by metro pair with at least 5 certified filings (every group that can carry percentiles), with the full occupation list of each employer; groups under 5 filings never get percentiles.
Read this before you rely on the numbers
LCA wages are the bottom of the offered range. Each filing's wage is the low end of the range the employer certified (WAGE_RATE_OF_PAY_FROM), so employer and LCA medians run low against what people are actually paid and against OEWS. Percentiles are unweighted per filing, and describe wages certified on visa filings, not all workers. Use the OEWS fields for the general labor market. median_vs_oews_ratio and ratio_to_oews compare the two. Percentiles can be null when BLS suppresses an estimate or top codes it.
Small groups move with one filing. A group with 5 to 9 filings still gets percentiles, but its p25, p50 and p75 are individual filings or sit between two adjacent filings, so one new application can move them, and p10 and p90 are interpolated between the two lowest or the two highest filings. Every response carries filings; use it to apply your own floor (for example, ignore groups under 10 or 20 filings). Informational only; not legal, immigration or financial advice.
Attribution requirements
Keep the attribution block (sources, release dates and notice) with any data you republish or display, or reproduce it as a visible credit. Derived from U.S. Department of Labor OFLC LCA disclosure data and BLS OEWS (U.S. government works). Not affiliated with or endorsed by DOL or BLS. Job-title matching uses the O*NET 30.0 Database by the U.S. Department of Labor, Employment and Training Administration (USDOL/ETA), used under the CC BY 4.0 license. Modified: titles were normalised and mapped to OEWS codes. USDOL/ETA has not approved, endorsed, or tested these modifications. O*NET® is a trademark of USDOL/ETA. Anything that uses title matching must carry that O*NET notice, which is also in every response's attribution.notice.
Pricing
| Plan | Price | Quota | Rate limit | Max search results | |
|---|---|---|---|---|---|
| free | $0 | 100 successful calls / month | 10 / minute | 10 | Get a free key |
| basic | $9 / month | 5,000 successful calls / month | 60 / minute | 25 | Subscribe |
| pro | $29 / month | 50,000 successful calls / month | 300 / minute | 50 | Subscribe |
| ultra | $99 / month | 500,000 successful calls / month | 600 / minute | 50 | Subscribe |
Only successful (2xx) calls count. Over quota you get 429 quota_exceeded until the next UTC month. Checkout is handled by Polar. Your key arrives by email (the address you pay with) within about 15 minutes of checkout. Cancel any time in Polar's customer portal; when a subscription ends or is refunded, its key drops to the free plan.