/api-reference · v1 stable · base URL https://api.placement.solutions Changelog →
API reference

API reference.

A REST API over daily-refreshed legal-hiring records, firm dossiers, and a 47-node practice taxonomy. Bearer authentication with prefixed keys, cursor pagination, idempotent writes, and one error envelope across every endpoint. Three steps to your first call.

v1 stable JSON over HTTPS TLS 1.3 only SOC 2 Type I in observation

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.

GET /v1/jobs
curl https://api.placement.solutions/v1/jobs?firm_id=frm_kirkland&limit=10 \
  -H "Authorization: Bearer pk_live_REPLACE_ME"

3. Read the response

200 OK
{
  "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.

PrefixPurposeAllowed 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

POST /v1/me/keys
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"]
  }'

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.

Storage. The plaintext key is shown once at creation and never again. We store only a SHA-256 hash. If you lose a key, create a new one and revoke the old.

Error envelope #

Every non-2xx response uses the same shape:

application/json
{
  "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.

CodeHTTPRetryCause
unauthenticated401noNo Authorization header. Add the header.
invalid_api_key401noHeader is present but the key is malformed, unknown, or revoked.
rate_limited429yesPer-minute or monthly quota exceeded. Honor Retry-After seconds.
scope_insufficient403noKey is valid but lacks the required scope. Use a key with broader scope or add the scope to this key.
ip_not_allowed403noSource IP is not in the key’s allowlist.
idempotency_key_conflict409noAn Idempotency-Key was reused with a different request body. Use a fresh key or send the original body.
body_too_large413noRequest body exceeds 200 KB. Split the request or use an export.
unsupported_media_type415noContent-Type is not application/json.
validation_error422condRequest body failed schema validation. The message field names the offending property.
not_found404noResource ID does not exist or is not visible to your tenant. We do not distinguish the two for security.
conflict409noResource state conflicts with the request (already exists, already revoked).
gone410noResource existed but has been permanently removed (deleted by the owner, retention expired).
payload_required400condA POST arrived with an empty body where a JSON object is required.
query_param_required400condA required query parameter was missing.
query_param_invalid400condA query parameter has the wrong type or is outside the allowed set.
cursor_invalid400nocursor is malformed, expired, or was issued for a different filter set. Restart from the first page.
page_size_invalid400condlimit is below 1 or above 500.
sort_invalid400condsort references a field that is not sortable on this endpoint.
filter_invalid400condA filter combination is unsupported (e.g. posted_after in the future).
engine_unavailable503yesA backend dependency is degraded. Retry with exponential backoff.
internal_error500yesUnexpected server error. Retry once; if it persists, contact support with the request_id.
gateway_timeout504yesUpstream timeout. Retry with backoff.
service_unavailable503yesMaintenance window or capacity event. Honor Retry-After.
deprecated299noSuccessful response with deprecation warning. The endpoint will sunset on the date in the Sunset header. Migrate.

Example: 429 Too Many Requests

429 + Retry-After: 28
{
  "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.
Cursors are opaque. Do not parse them. Do not store fields from the encoded payload. The internal format may change without notice; the contract is only that an unmodified cursor returned by us will continue working for at least 24 hours.

Worked example: walk every recent lateral move

GET /v1/lateral-moves
# 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"

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.

TierSustainedBurstMonthlyWebhooks
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

HeaderMeaning
RateLimit-LimitSustained ceiling for the current key, per minute.
RateLimit-RemainingCalls remaining in the current minute window.
RateLimit-ResetUnix seconds when the window resets.
Retry-AfterPresent 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

POST /v1/exports
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" }'
Network errors are when this matters. If your 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 #

GET /v1/jobs Search and filter the active job index.

Scopes

jobs:read

Query parameters

NameTypeRequiredDescription
firm_idstringnoFilter by firm. Can be repeated.
practice_areastringnoPractice slug from /v1/practice-areas.
marketstringnoGeographic market slug from /v1/markets.
posted_afterdatenoRFC 3339 date. Inclusive lower bound on posted_at.
qstringnoFull-text search across title and description.
cursorstringnoPagination cursor.
limitintno1–500. Default 100.

Example

GET /v1/jobs
curl "https://api.placement.solutions/v1/jobs?practice_area=private-equity&market=new-york&limit=50" \
  -H "Authorization: Bearer pk_live_REPLACE_ME"

Retrieve a job #

GET /v1/jobs/{id} Single job with full sourcing chain.

Scopes

jobs:read

Path parameters

idstringyesJob identifier (e.g. job_01J9X5AC...).

Example

GET /v1/jobs/{id}
curl https://api.placement.solutions/v1/jobs/job_01J9X5AC2P3K8Q4N7M6Y3R2T1V \
  -H "Authorization: Bearer pk_live_REPLACE_ME"

List firms #

GET /v1/firms Filter the firm dossier index.

Scopes

firms:read

Query parameters

NameTypeRequiredDescription
amlaw_tierstringnoamlaw_50, amlaw_100, amlaw_200, global_200, boutique.
marketstringnoFilter by office market.
practicestringnoFilter to firms with the practice in their mix.
headcount_gteintnoMinimum total attorney headcount.
cursorstringnoPagination cursor.
limitintno1–500. Default 100.

Retrieve a firm #

GET /v1/firms/{id} Full firm dossier: headcount, partners, practice mix, offices, hiring velocity.

Scopes

firms:read

Example response

200 OK
{
  "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 #

GET /v1/firms/{id}/headcount Headcount as-of any date back to 2024-06-01.

Query parameters

as_ofdatenoRFC 3339 date. Defaults to today.

Hiring velocity #

GET /v1/firms/{id}/hiring-velocity Time series of postings opened and closed over a window.

Query parameters

windowstringnoOne of 30d, 90d, 12m. Default 90d.

Compare firms #

POST /v1/firms/compare Side-by-side comparison of 2 to 4 firms.

Scopes

firms:read

Request body

idsarrayyes2 to 4 firm IDs.
metricsarraynoSubset of metrics to return. Default all.

Example

POST /v1/firms/compare
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"] }'

Lateral moves #

GET /v1/lateral-moves Detected lateral hires with confidence and signal trail.

Scopes

lateral_moves:read

Query parameters

NameTypeRequiredDescription
from_firm_idstringnoOrigin firm.
to_firm_idstringnoDestination firm.
practicestringnoPractice slug.
marketstringnoMarket slug.
confidence_gtenumberno0–1. Minimum confidence score.
sincedatenoRFC 3339. Inclusive lower bound on detection date.
cursorstringnoPagination cursor.
limitintno1–500. Default 100.

Partner departures #

GET /v1/partner-departures Detected partner exits with multi-signal weight breakdown.

Scopes

departures:read

Query parameters

NameTypeRequiredDescription
firm_idstringnoFilter by firm.
confidence_gtenumberno0–1.
signal_count_gteintnoMinimum number of confirming signals.
sincedatenoLower bound on detection date.

Practice areas #

GET /v1/practice-areas 47-node practice taxonomy. Public, free, cacheable.

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 #

GET /v1/markets Geographic market list (US metros, EMEA, APAC, LatAm).

Scopes

None. Open endpoint.

Usage events #

GET /v1/usage/events Per-call audit log for the current account.

Scopes

usage:read

Query parameters

NameTypeRequiredDescription
sincedatenoLower bound. Default 24 hours ago.
untildatenoUpper bound. Default now.
endpointstringnoFilter to a single endpoint path.
api_key_idstringnoFilter to a single key.
cursorstringnoPagination cursor.
limitintno1–500. Default 100.

Account info #

GET /v1/me Current tenant, user, tier, and token-introspection-style metadata for the calling key.

Scopes

None. Returns information about whichever key is calling.

Example response

200 OK
{
  "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 #

POST /v1/me/keys Issue a new API key. Plaintext returned once.

Scopes

keys:write

Request body

NameTypeRequiredDescription
prefixstringyesOne of pk_live_, sk_live_, rk_live_, sk_test_.
scopesarrayyesList of scope strings. See the scope reference.
ip_allowlistarraynoIPv4, IPv6, or CIDR entries. Empty means all IPs.
expires_atdatenoRFC 3339. Auto-revoke at this time. Recommended for CI keys.
descriptionstringnoFree-form. Helps you find the key in the dashboard.

Exports #

GET /v1/exports List recent export jobs and their signed download URLs.

Scopes

exports:read

Query parameters

NameTypeRequiredDescription
statusstringnoqueued, running, ready, failed, expired.
cursorstringnoPagination cursor.
limitintno1–500. Default 100.

Example response

200 OK
{
  "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.