Quickstart #
Three steps. A sandbox key, your first call, a walk through the response shape. The same fields in the same positions you will see in production.
1. Get a key
Sign in, open the dashboard, click Create key. Choose sk_test_ for sandbox or pk_live_ for production reads. The plaintext is shown once. Save it to your secrets manager.
2. Make your first request
List the most recent jobs at a single firm.
curl https://api.placement.solutions/v1/jobs?firm_id=frm_kirkland&limit=10 \ -H "Authorization: Bearer pk_live_REPLACE_ME"
import requests resp = requests.get( "https://api.placement.solutions/v1/jobs", params={"firm_id": "frm_kirkland", "limit": 10}, headers={"Authorization": "Bearer pk_live_REPLACE_ME"}, timeout=10, ) resp.raise_for_status() for job in resp.json()["data"]: print(job["id"], job["title"])
const resp = await fetch( "https://api.placement.solutions/v1/jobs?firm_id=frm_kirkland&limit=10", { headers: { "Authorization": "Bearer pk_live_REPLACE_ME" } } ); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const { data } = await resp.json(); for (const job of data) console.log(job.id, job.title);
3. Read the response
{
"data": [
{
"id": "job_01J9X5AC2P3K8Q4N7M6Y3R2T1V",
"firm_id": "frm_kirkland",
"title": "Capital Markets Associate",
"location": "New York, NY",
"practice_area": "capital-markets",
"seniority": "associate",
"is_remote": false,
"salary_min": 225000,
"salary_max": 365000,
"posted_at": "2026-05-04T09:14:22Z",
"url": "https://api.placement.solutions/jobs/job_01J9X5AC2P3K8Q4N7M6Y3R2T1V/capital-markets-associate"
}
],
"next_cursor": "eyJsYXN0X2lkIjoiam9iXzAxSjlYNUFDLi4uIn0",
"has_more": true
}
You now have the request shape, the error envelope (covered next), and the cursor pagination contract. From here, every endpoint follows the same patterns.
Authentication #
Every request must carry an Authorization: Bearer <key> header. Keys self-identify by prefix.
| Prefix | Purpose | Allowed operations |
|---|---|---|
| pk_live_ | Read keys | All GET endpoints. Cannot create webhooks, rotate keys, or modify the account. |
| sk_live_ | Admin keys | Everything pk_live_ can do, plus key creation/revocation, webhook registration, account changes. Treat as root. |
| rk_live_ | Rotation keys | Single-purpose keys for rotating the signing secret of a webhook endpoint or another key. Cannot read data. |
| sk_test_ | Sandbox keys | Free-tier sandbox. Synthetic data only. 1,000 calls/day cap. |
Create a key
curl -X POST https://api.placement.solutions/v1/me/keys \ -H "Authorization: Bearer sk_live_REPLACE_ME" \ -H "Content-Type: application/json" \ -d '{ "prefix": "pk_live_", "scopes": ["jobs:read", "firms:read"], "ip_allowlist": ["203.0.113.0/24"] }'
import requests resp = requests.post( "https://api.placement.solutions/v1/me/keys", headers={"Authorization": "Bearer sk_live_REPLACE_ME"}, json={"prefix": "pk_live_", "scopes": ["jobs:read", "firms:read"], "ip_allowlist": ["203.0.113.0/24"]}, ) key = resp.json()["key"] # SAVE NOW. Will not be shown again.
IP allowlist
Each key can be locked to one or more IPv4, IPv6, or CIDR ranges. Calls from outside the allowlist return ip_not_allowed. Leave the allowlist empty to accept any source IP. We strongly recommend setting it on every pk_live_ key in production.
Rotation
Create the new key first, swap it into your application, then revoke the old one. There is no "rotate in place" operation; key creation and revocation are atomic and independent. Keys carry a last_used_at timestamp so you can confirm cutover before revoking.
Error envelope #
Every non-2xx response uses the same shape:
{
"error": {
"code": "rate_limited",
"message": "You have exceeded your monthly call quota. Resets 2026-06-01.",
"request_id": "req_01J9X5AC2P3K8Q4N7M6Y3R2T1V",
"doc_url": "https://api.placement.solutions/api-reference#err-rate_limited"
}
}
Always log request_id on errors. When you contact support, that is the first thing we ask for.
Code reference
Twenty-four named codes. The retry column tells you whether a retry with the same input could succeed: yes means a transient condition that may resolve, cond means retry only after fixing the request, no means do not retry without operator action.
| Code | HTTP | Retry | Cause |
|---|---|---|---|
| unauthenticated | 401 | no | No Authorization header. Add the header. |
| invalid_api_key | 401 | no | Header is present but the key is malformed, unknown, or revoked. |
| rate_limited | 429 | yes | Per-minute or monthly quota exceeded. Honor Retry-After seconds. |
| scope_insufficient | 403 | no | Key is valid but lacks the required scope. Use a key with broader scope or add the scope to this key. |
| ip_not_allowed | 403 | no | Source IP is not in the key’s allowlist. |
| idempotency_key_conflict | 409 | no | An Idempotency-Key was reused with a different request body. Use a fresh key or send the original body. |
| body_too_large | 413 | no | Request body exceeds 200 KB. Split the request or use an export. |
| unsupported_media_type | 415 | no | Content-Type is not application/json. |
| validation_error | 422 | cond | Request body failed schema validation. The message field names the offending property. |
| not_found | 404 | no | Resource ID does not exist or is not visible to your tenant. We do not distinguish the two for security. |
| conflict | 409 | no | Resource state conflicts with the request (already exists, already revoked). |
| gone | 410 | no | Resource existed but has been permanently removed (deleted by the owner, retention expired). |
| payload_required | 400 | cond | A POST arrived with an empty body where a JSON object is required. |
| query_param_required | 400 | cond | A required query parameter was missing. |
| query_param_invalid | 400 | cond | A query parameter has the wrong type or is outside the allowed set. |
| cursor_invalid | 400 | no | cursor is malformed, expired, or was issued for a different filter set. Restart from the first page. |
| page_size_invalid | 400 | cond | limit is below 1 or above 500. |
| sort_invalid | 400 | cond | sort references a field that is not sortable on this endpoint. |
| filter_invalid | 400 | cond | A filter combination is unsupported (e.g. posted_after in the future). |
| engine_unavailable | 503 | yes | A backend dependency is degraded. Retry with exponential backoff. |
| internal_error | 500 | yes | Unexpected server error. Retry once; if it persists, contact support with the request_id. |
| gateway_timeout | 504 | yes | Upstream timeout. Retry with backoff. |
| service_unavailable | 503 | yes | Maintenance window or capacity event. Honor Retry-After. |
| deprecated | 299 | no | Successful response with deprecation warning. The endpoint will sunset on the date in the Sunset header. Migrate. |
Example: 429 Too Many Requests
{
"error": {
"code": "rate_limited",
"message": "Sustained rate exceeded. Try again in 28 seconds.",
"request_id": "req_01J9X5BC4Q5M0R6P9N8Z5T4U3X",
"doc_url": "https://api.placement.solutions/api-reference#err-rate_limited"
}
}
Cursor pagination #
Every list endpoint paginates with opaque cursors. Pass cursor and limit on the request; the response includes next_cursor until the page set is exhausted.
limit— integer 1–500. Default 100. Maximum 500.cursor— opaque base64. Echo it back unchanged on the next call.next_cursor— present in the response while more pages exist. Absent on the final page.has_more— boolean. Always present.
Worked example: walk every recent lateral move
# first page curl "https://api.placement.solutions/v1/lateral-moves?since=2026-04-01&limit=200" \ -H "Authorization: Bearer pk_live_REPLACE_ME" # second page (echo next_cursor from the first response) curl "https://api.placement.solutions/v1/lateral-moves?since=2026-04-01&limit=200&cursor=eyJsYXN0X2lkIjoibG1fMDFKOVg1Li4uIn0" \ -H "Authorization: Bearer pk_live_REPLACE_ME"
import requests def walk(url, params, headers): cursor = None while True: p = {**params, "limit": 500} if cursor: p["cursor"] = cursor r = requests.get(url, params=p, headers=headers, timeout=10) r.raise_for_status() body = r.json() yield from body["data"] if not body.get("has_more"): return cursor = body["next_cursor"] for move in walk( "https://api.placement.solutions/v1/lateral-moves", {"since": "2026-04-01"}, {"Authorization": "Bearer pk_live_REPLACE_ME"}, ): print(move["id"], move["to_firm_id"])
Rate limits #
Two ceilings. A sustained per-minute rate that protects the platform. A monthly quota that meters your tier. The first signals back-off; the second signals upgrade.
| Tier | Sustained | Burst | Monthly | Webhooks |
|---|---|---|---|---|
| Sandboxfree | 30 req/min | 60 req | 1,000 / day | not included |
| Read-only$2,995/mo | 60 req/min | 120 req | 10,000 | not included |
| Webhook$5,995/mo | unlimited | 300 req/min burst | unlimited | included |
| Bulk$15K/qtr | 600 req/min | 1,200 req | unlimited | included + bulk replay |
Headers on every response
| Header | Meaning |
|---|---|
| RateLimit-Limit | Sustained ceiling for the current key, per minute. |
| RateLimit-Remaining | Calls remaining in the current minute window. |
| RateLimit-Reset | Unix seconds when the window resets. |
| Retry-After | Present on 429 only. Seconds to wait before retrying. |
When you cross either ceiling, the response is 429 with the rate_limited envelope. Honor Retry-After. A monthly-quota 429 also includes a message field that names the cycle reset date.
Idempotency #
Every POST endpoint accepts an Idempotency-Key header. We dedupe within a 24-hour window. If you replay the exact same request body with the same key inside the window, we return the original response and skip side effects. If the body differs from the original, we return idempotency_key_conflict.
Header contract
- Up to 255 characters. Letters, digits, hyphens. UUID v4 is recommended.
- Window is 24 hours from first observation.
- One key per logical operation. Reusing the same key for two different writes is the bug we are protecting you against.
Worked example
curl -X POST https://api.placement.solutions/v1/exports \ -H "Authorization: Bearer pk_live_REPLACE_ME" \ -H "Idempotency-Key: 6e9d1f4c-2a4b-4f2e-9a1c-93b7d2c4e5f6" \ -H "Content-Type: application/json" \ -d '{ "kind": "lateral_moves", "filters": {"since": "2026-01-01"}, "format": "csv" }'
import requests, uuid key = str(uuid.uuid4()) def submit(): return requests.post( "https://api.placement.solutions/v1/exports", headers={ "Authorization": "Bearer pk_live_REPLACE_ME", "Idempotency-Key": key, }, json={"kind": "lateral_moves", "filters": {"since": "2026-01-01"}, "format": "csv"}, ) # Safe to retry on network error; the second call returns the original response. resp = submit()
POST times out and you do not know whether the server processed it, retry with the same Idempotency-Key. Either you get the original 2xx (success) or idempotency_key_conflict (your body changed). You will never accidentally trigger the side effect twice.
Versioning #
Major versions live in the URL. The current version is v1. We do not use header-based versioning.
What can change without notice
- New endpoints.
- New optional query parameters.
- New fields on existing response objects (clients must ignore unknown fields).
- New enum values on existing fields.
- New event types on webhooks.
What gives six months of notice
- Removing or renaming an endpoint.
- Removing or renaming a response field.
- Tightening a query parameter (changing default, narrowing accepted values).
- Removing an enum value.
Deprecated endpoints respond with 299 Deprecated in addition to their normal 2xx, plus a Sunset header carrying the cutover date and a Link header pointing at the migration guide. The changelog is the source of truth.
List jobs #
Scopes
jobs:read
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
| firm_id | string | no | Filter by firm. Can be repeated. |
| practice_area | string | no | Practice slug from /v1/practice-areas. |
| market | string | no | Geographic market slug from /v1/markets. |
| posted_after | date | no | RFC 3339 date. Inclusive lower bound on posted_at. |
| q | string | no | Full-text search across title and description. |
| cursor | string | no | Pagination cursor. |
| limit | int | no | 1–500. Default 100. |
Example
curl "https://api.placement.solutions/v1/jobs?practice_area=private-equity&market=new-york&limit=50" \ -H "Authorization: Bearer pk_live_REPLACE_ME"
requests.get("https://api.placement.solutions/v1/jobs", params={"practice_area": "private-equity", "market": "new-york", "limit": 50}, headers={"Authorization": "Bearer pk_live_REPLACE_ME"}).json()
await fetch("https://api.placement.solutions/v1/jobs?practice_area=private-equity&market=new-york&limit=50", { headers: { "Authorization": "Bearer pk_live_REPLACE_ME" } }).then(r => r.json());
Retrieve a job #
Scopes
jobs:read
Path parameters
| id | string | yes | Job identifier (e.g. job_01J9X5AC...). |
Example
curl https://api.placement.solutions/v1/jobs/job_01J9X5AC2P3K8Q4N7M6Y3R2T1V \ -H "Authorization: Bearer pk_live_REPLACE_ME"
requests.get("https://api.placement.solutions/v1/jobs/job_01J9X5AC2P3K8Q4N7M6Y3R2T1V", headers={"Authorization": "Bearer pk_live_REPLACE_ME"}).json()
await fetch("https://api.placement.solutions/v1/jobs/job_01J9X5AC2P3K8Q4N7M6Y3R2T1V", { headers: { "Authorization": "Bearer pk_live_REPLACE_ME" } }).then(r => r.json());
List firms #
Scopes
firms:read
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
| amlaw_tier | string | no | amlaw_50, amlaw_100, amlaw_200, global_200, boutique. |
| market | string | no | Filter by office market. |
| practice | string | no | Filter to firms with the practice in their mix. |
| headcount_gte | int | no | Minimum total attorney headcount. |
| cursor | string | no | Pagination cursor. |
| limit | int | no | 1–500. Default 100. |
Retrieve a firm #
Scopes
firms:read
Example response
{
"id": "frm_kirkland",
"name": "Kirkland & Ellis",
"amlaw_tier": "amlaw_50",
"headcount_total": 3402,
"partner_count": 1198,
"practice_mix": [
{"slug": "private-equity", "share": 0.31},
{"slug": "litigation", "share": 0.24},
{"slug": "restructuring", "share": 0.11}
],
"offices": ["new-york", "chicago", "los-angeles", "london"],
"hiring_velocity_30d": 47,
"as_of": "2026-05-06"
}
Firm headcount timeline #
Query parameters
| as_of | date | no | RFC 3339 date. Defaults to today. |
Hiring velocity #
Query parameters
| window | string | no | One of 30d, 90d, 12m. Default 90d. |
Compare firms #
Scopes
firms:read
Request body
| ids | array | yes | 2 to 4 firm IDs. |
| metrics | array | no | Subset of metrics to return. Default all. |
Example
curl -X POST https://api.placement.solutions/v1/firms/compare \ -H "Authorization: Bearer pk_live_REPLACE_ME" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "ids": ["frm_kirkland", "frm_paul_weiss", "frm_skadden"] }'
requests.post("https://api.placement.solutions/v1/firms/compare", headers={ "Authorization": "Bearer pk_live_REPLACE_ME", "Idempotency-Key": str(uuid.uuid4()), }, json={"ids": ["frm_kirkland", "frm_paul_weiss", "frm_skadden"]}).json()
Lateral moves #
Scopes
lateral_moves:read
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
| from_firm_id | string | no | Origin firm. |
| to_firm_id | string | no | Destination firm. |
| practice | string | no | Practice slug. |
| market | string | no | Market slug. |
| confidence_gte | number | no | 0–1. Minimum confidence score. |
| since | date | no | RFC 3339. Inclusive lower bound on detection date. |
| cursor | string | no | Pagination cursor. |
| limit | int | no | 1–500. Default 100. |
Partner departures #
Scopes
departures:read
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
| firm_id | string | no | Filter by firm. |
| confidence_gte | number | no | 0–1. |
| signal_count_gte | int | no | Minimum number of confirming signals. |
| since | date | no | Lower bound on detection date. |
Practice areas #
Scopes
None. Open endpoint.
Notes
Leaf slugs are stable forever. Deprecated slugs continue to resolve via redirect for 18 months. Every response includes a taxonomy_version string; pin it on your end if you cache.
Markets #
Scopes
None. Open endpoint.
Usage events #
Scopes
usage:read
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
| since | date | no | Lower bound. Default 24 hours ago. |
| until | date | no | Upper bound. Default now. |
| endpoint | string | no | Filter to a single endpoint path. |
| api_key_id | string | no | Filter to a single key. |
| cursor | string | no | Pagination cursor. |
| limit | int | no | 1–500. Default 100. |
Account info #
Scopes
None. Returns information about whichever key is calling.
Example response
{
"tenant": { "id": "ten_01J9X...", "name": "Acme Legal Newsroom", "tier": "webhook", "data_residency": "us" },
"key": { "id": "key_01J9X...", "prefix": "pk_live_", "scopes": ["jobs:read", "firms:read"], "ip_allowlist": ["203.0.113.0/24"] },
"limits": { "month_quota": null, "month_used": 38421, "minute_limit": null, "burst_limit": 300, "burst_remaining": 287 }
}
Create API key #
Scopes
keys:write
Request body
| Name | Type | Required | Description |
|---|---|---|---|
| prefix | string | yes | One of pk_live_, sk_live_, rk_live_, sk_test_. |
| scopes | array | yes | List of scope strings. See the scope reference. |
| ip_allowlist | array | no | IPv4, IPv6, or CIDR entries. Empty means all IPs. |
| expires_at | date | no | RFC 3339. Auto-revoke at this time. Recommended for CI keys. |
| description | string | no | Free-form. Helps you find the key in the dashboard. |
Exports #
Scopes
exports:read
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
| status | string | no | queued, running, ready, failed, expired. |
| cursor | string | no | Pagination cursor. |
| limit | int | no | 1–500. Default 100. |
Example response
{
"data": [
{
"id": "exp_01J9X...",
"kind": "lateral_moves",
"format": "csv",
"status": "ready",
"row_count": 18241,
"signed_url": "https://exports.placement.solutions/exp_01J9X.../moves.csv?expires=...",
"expires_at": "2026-05-07T14:22:31Z"
}
],
"next_cursor": null,
"has_more": false
}
Signed URLs expire 24 hours after issue. After expiry, fetch the export again from this endpoint to get a fresh URL; the underlying file is retained for 7 days.