/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.

See pricing and tier details for full feature breakdown, FAQ, and contract options.

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

Tier

Sandbox and above (synthetic data on Sandbox).

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

Tier

Sandbox and above (synthetic data on Sandbox).

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

Tier

Sandbox and above (synthetic data on Sandbox).

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

Tier

Sandbox and above (synthetic data on Sandbox).

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.

Scopes

firms:read + headcount:read

Tier

Sandbox and above (synthetic data on Sandbox).

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.

Scopes

firms:read + hiring_velocity:read

Tier

Sandbox and above (synthetic data on Sandbox).

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

Tier

Sandbox and above (synthetic data on Sandbox).

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

Tier

Sandbox and above (synthetic data on Sandbox).

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

Tier

Sandbox and above (synthetic data on Sandbox).

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.

Tier

All tiers including Sandbox. Public taxonomy.

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.

Tier

All tiers including Sandbox. Public taxonomy.

Usage events #

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

Scopes

usage:read

Tier

All tiers including Sandbox.

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.

Tier

All tiers including Sandbox.

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

Tier

All tiers including Sandbox.

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

Tier

Read-only and above. CSV or JSON on Read-only, Parquet adds at Webhook tier, unlimited at Bulk.

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.

Reverse-engineer a job description #

POST /v1/reverse-engineer Pass a staffing-agency job description, an in-house counsel posting, or a redacted spec; receive a ranked list of candidate firms or candidate practitioners with confidence scores and reasoning chips.

Scopes

reverse_engineer:execute

Tier

Sandbox 10/day, Read-only 50/day, Webhook 500/day, Bulk and Custom unlimited. Calls cost five weighted units each.

This is the wedge. Most legal hiring data products tell you who moved. We also tell you, given a description that hides the firm name, which firm or practitioner is the most likely match. Buyers use this to identify the hiring side of a confidential search and to attribute anonymized recruiter postings.

Request body

NameTypeRequiredDescription
textstringone ofRaw job description, plain text. Up to 32 KB. Either text or file_url must be provided.
file_urlstringone ofHTTPS URL pointing to a PDF or DOCX. Must be reachable from our crawler IP range. We fetch once and discard.
targetstringnofirm (default) or practitioner. Selects the resolution head.
top_kintno1 to 25. Default 10. Number of ranked candidates to return.
confidence_floornumberno0 to 1. Suppress candidates below this score. Default 0.20.

Example

POST /v1/reverse-engineer
curl -X POST https://api.placement.solutions/v1/reverse-engineer \
  -H "Authorization: Bearer pk_live_REPLACE_ME" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{ "text": "Confidential search for senior associate, complex commercial litigation, MDL experience, NY office of an AmLaw 50 firm with strong life-sciences book.", "target": "firm", "top_k": 5 }'

Example response

200 OK
{
  "data": [
    {
      "rank": 1,
      "firm_id": "frm_paul_weiss",
      "firm_name": "Paul Weiss",
      "confidence_score": 0.91,
      "confidence_tier": "high",
      "reasoning": [
        { "chip": "Lit-MDL volume in NY", "weight": 0.31 },
        { "chip": "Life-sciences book Q1 2026", "weight": 0.24 },
        { "chip": "AmLaw 50 tier match", "weight": 0.18 },
        { "chip": "Senior-associate hiring velocity", "weight": 0.18 }
      ]
    },
    {
      "rank": 2,
      "firm_id": "frm_paul_hastings",
      "firm_name": "Paul Hastings",
      "confidence_score": 0.78,
      "confidence_tier": "medium",
      "reasoning": [ { "chip": "NY MDL footprint", "weight": 0.27 } ]
    }
  ],
  "request_id": "req_01J9X..."
}
Inputs are not retained. The submitted text or fetched file is held only long enough to score it and is then discarded. We do not train on customer queries, we do not surface the input back to other tenants, and the request body is excluded from logging beyond a SHA-256 fingerprint kept for abuse triage.

List API keys #

GET /v1/me/keys All keys minted under the calling tenant. Hash is returned; plaintext is never shown after creation.

Scopes

me:read

Tier

All tiers including Sandbox.

Query parameters

NameTypeRequiredDescription
prefixstringnoFilter by prefix: pk_live_, sk_live_, rk_live_, sk_test_.
statusstringnoactive, revoked, expired.
cursorstringnoPagination cursor.
limitintno1 to 200. Default 50.

Retrieve an API key #

GET /v1/me/keys/{id} Single key record. Plaintext is not returned. Useful for confirming scopes, IP allowlist, and last-used timestamp.

Scopes

me:read

Tier

All tiers including Sandbox.

Update an API key #

PATCH /v1/me/keys/{id} Adjust scopes, IP allowlist, or description. The plaintext key never changes; you mutate the metadata only.

Scopes

me:write

Tier

All tiers including Sandbox.

Request body

NameTypeRequiredDescription
scopesarraynoReplace the scope set. Cannot exceed the parent key's scope ceiling.
ip_allowlistarraynoReplace the IP allowlist. Empty array means open.
descriptionstringnoFree-form. Max 256 characters.
expires_atdatenoRFC 3339. Set to null to clear.

Revoke an API key #

DELETE /v1/me/keys/{id} Immediate revoke. The next request authenticated with this key fails with revoked_api_key. There is no undo.

Scopes

me:write

Tier

All tiers including Sandbox.

Use this for compromise-suspected keys. For planned rotation, prefer the dual-active flow described under Authentication: mint the new key, swap it into your application, confirm last_used_at moved off the old key, then revoke the old one.

List team members #

GET /v1/me/team Users on the calling tenant with role, status, and last-active timestamp.

Scopes

team:read

Tier

Read-only and above.

Invite a team member #

POST /v1/me/team Send an invitation email. Pending invitations expire 14 days from issue.

Scopes

team:write

Tier

Read-only and above (admin role required on Read-only).

Request body

NameTypeRequiredDescription
emailstringyesRFC 5322 email. Domain must match the tenant's verified domain unless cross-domain invitation is enabled on Custom Build.
rolestringyesOne of owner, admin, developer, billing, viewer.
messagestringnoOptional message included in the invitation email. Up to 1 KB.

Update a team member #

PATCH /v1/me/team/{user_id} Change role or status on an existing member. Owners cannot demote themselves; transfer ownership first.

Scopes

team:write

Tier

Read-only and above.

Remove a team member #

DELETE /v1/me/team/{user_id} Detach the user from the tenant. Their session is invalidated within 60 seconds. Keys minted by the user remain active until separately revoked.

Scopes

team:write

Tier

Read-only and above.

Create an export #

POST /v1/exports Queue an asynchronous export job. Returns an export ID immediately; poll the get-by-id endpoint or wait for the export.ready webhook.

Scopes

exports:write

Tier

Read-only ships CSV or JSON, one export per day. Webhook adds Parquet and lifts the cap to five per day. Bulk and Custom are unlimited.

Request body

NameTypeRequiredDescription
kindstringyesOne of jobs, firms, lateral_moves, partner_departures, headcount_history, hiring_velocity_series.
filtersobjectnoSame filter shape as the corresponding list endpoint. Omit for full corpus on the chosen kind.
formatstringyescsv, json, or parquet. Parquet requires Webhook tier or higher.
columnsarraynoSubset of fields to export. Default is the full record. Useful for keeping CSV row width manageable.
deliverystringnosigned_url (default) returns a 24-hour signed URL. s3 uploads to a bucket you own; supply delivery_target alongside.

Example

POST /v1/exports
curl -X POST https://api.placement.solutions/v1/exports \
  -H "Authorization: Bearer pk_live_REPLACE_ME" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{ "kind": "lateral_moves", "filters": {"since": "2026-01-01"}, "format": "csv" }'

Example response

202 Accepted
{
  "id": "exp_01J9X...",
  "status": "queued",
  "kind": "lateral_moves",
  "format": "csv",
  "estimated_row_count": 18241,
  "created_at": "2026-05-06T14:22:31Z"
}

Retrieve an export #

GET /v1/exports/{id} Poll a single export job. Once status=ready, the response includes a 24-hour signed URL.

Scopes

exports:read

Tier

Read-only and above. Following the signed URL to pull the payload requires bulk:download, which is granted on Bulk and Custom tiers only; Read-only and Webhook receive the metadata and the URL but the URL refuses payload delivery without the scope.

Example response

200 OK
{
  "id": "exp_01J9X...",
  "kind": "lateral_moves",
  "format": "csv",
  "status": "ready",
  "row_count": 18241,
  "byte_size": 6914223,
  "signed_url": "https://exports.placement.solutions/exp_01J9X.../moves.csv?expires=...",
  "expires_at": "2026-05-07T14:22:31Z",
  "created_at": "2026-05-06T14:22:31Z",
  "ready_at":   "2026-05-06T14:24:08Z"
}

If the export is still processing, the response carries status: "queued" or "running" and omits signed_url. Failed exports include an error object with a code and message; the canonical failure mode is export_too_large when the requested row count crosses the tier ceiling.