Agent data plane — REST API
Before you read further: The agent data plane is not yet open for self-serve signup. You need an issued API key and base URL before any live request on this page will work — see Agent data plane for how access is arranged today.
The full /v1 reference. For what the plane is and how it is billed, start with the Agent data plane overview. Every operation below is also machine-readable as an OpenAPI document: openapi/plane-v1.yaml. Before you have a key, the fixture gallery has one runnable command per operation against a recorded, schema-checked response on this origin. Each operation’s status — live, code-complete, or planned — is also published as data at /plane-status.json.
Base URL
The plane is not yet open for self-serve signup and has no public base URL. You do not need one to start — every operation below has a recorded fixture at an absolute URL on this origin, and the whole surface runs locally as a mock. Once a base URL and key are issued, the examples below assume you have exported them:
export PLANE_BASE_URL="…" # issued with your first key
export PLANE_API_KEY="…"
Authentication
Every request carries your key in the X-API-Key header:
X-API-Key: YOUR_API_KEY
| Situation | Response |
|---|---|
No X-API-Key header at all | 401 |
| Key is unknown, or has been revoked | 403 |
| A company, key, watch or webhook that isn’t yours, or doesn’t exist | 404 |
Unknown and revoked keys are deliberately indistinguishable. Every request is scoped to your own customer_id: you can never read, meter against, or revoke another customer’s anything.
Idempotency
Every metered route accepts an optional Idempotency-Key request header. Send the same key on a retry of the same logical call and it bills exactly once rather than once per attempt (NFR-D4):
Idempotency-Key: <your-token>
- Optional, off by default. Omit it and every call bills independently — the original behavior. You opt in only where a retry would otherwise double-bill.
- Bounded to 24 hours. A key dedups from its first use until 24h later, then bills again. The window is fixed, not sliding: a retry that straddles the 24h boundary derives a fresh bill, so it bills twice. That gap is the accepted cost of a stateless key — retry well inside the window if you need the guarantee to hold.
- Scoped to
(key, endpoint). The token is namespaced per API key and per endpoint, so two customers may reuse the same token safely, and one token reused across two endpoints bills each endpoint once.
/v1/clay/enrich accepts the header too, but a Clay column can’t attach a per-row key from its UI, so Clay enrichment bills per call — see the Clay billing note.
Response conventions
as_of. Every company and market read carries an as_of timestamp — the instant the answer describes.
The provenance envelope. On the four history-backed reads (hiring-pulse, first-hire, repost-pain, ats-migrations) each headline value arrives wrapped:
{
"value": 12,
"source_board": "greenhouse",
"observed_at": "2026-07-08T04:11:07Z",
"confidence": 0.94
}
source_board and observed_at may be null when a value is an aggregate across boards rather than a specific posting. The remaining reads (is-hiring, open-reqs, enrichment) return plain scalars plus the shared as_of.
Freshness and billing headers. Every metered read sets both:
| Header | Meaning |
|---|---|
X-Data-Freshness | The real observation time of the underlying data |
X-Meter-Class | How this read was billed: cached or fresh |
They are not set on 202 cold-tail responses, nor on /v1/events, /v1/whoami, /v1/usage or the key/webhook/watch management routes.
Your account
Who am I
GET /v1/whoami
What the presented key resolves to. Useful as a connectivity check.
curl -H "X-API-Key: $PLANE_API_KEY" "$PLANE_BASE_URL/v1/whoami"
{ "customer_id": "acme", "tier": "paid", "plan": null, "scopes": [] }
Issue a key
POST /v1/keys
Mint another key for your own account — the customer_id is always taken from the authenticated caller, never from the request. The raw key is returned once and stored only as a hash; we cannot recover it for you.
| Field | Notes |
|---|---|
tier | The tier label recorded on the key (visible via /v1/whoami) |
curl -X POST -H "X-API-Key: $PLANE_API_KEY" -H 'Content-Type: application/json' \
-d '{"tier": "paid"}' "$PLANE_BASE_URL/v1/keys"
{ "id": 42, "key": "…shown once…", "customer_id": "acme", "tier": "paid" }
Returns 201.
Revoke a key
DELETE /v1/keys/{key_id}
Returns 204. Revoking a key id that belongs to someone else returns 404 — identical to revoking one that never existed.
Usage
GET /v1/usage?window=24
Your metered usage over a rolling window, aggregated across every key your account holds.
| Query parameter | Default | Notes |
|---|---|---|
window | 24 | Window size in hours |
{
"calls": 1840,
"changes": 22791,
"watches": 12,
"forced_fresh": 3,
"by_meter_class": { "cached": 1801, "fresh": 42 }
}
Tier 0 — cached reads
Each bills one call unit at class cached, and is floored on data freshness (see Tiers and metering).
Is this company hiring?
GET /v1/companies/{company_id}/is-hiring
The cheap qualifying gate — call it before spending on a richer read.
curl -H "X-API-Key: $PLANE_API_KEY" \
"$PLANE_BASE_URL/v1/companies/4412/is-hiring"
{ "is_hiring": true, "open_req_count": 7, "as_of": "2026-07-08T04:11:07Z" }
Open requisitions
GET /v1/companies/{company_id}/open-reqs
The company’s currently active reqs, deduped across boards — the same real job posted to two boards appears once, with both boards listed.
| Query parameter | Default | Notes |
|---|---|---|
function | — | Filter by function |
country | — | Filter by country |
limit | 50 | Page size |
{
"reqs": [
{
"title": "Senior Backend Engineer",
"function": "engineering",
"country": "DE",
"first_seen": "2026-06-02T10:00:00Z",
"boards": ["greenhouse", "linkedin"]
}
],
"as_of": "2026-07-08T04:11:07Z"
}
Company enrichment
GET /v1/companies/{company_id}/enrichment
Basic firmographics derived from ATS and board sources. 404 when the company is unknown.
{
"name": "zollsoft GmbH",
"domain": "zollsoft.de",
"hq_country": "DE",
"boards": ["greenhouse"],
"as_of": "2026-07-08T04:11:07Z"
}
First hire in a function
GET /v1/companies/{company_id}/first-hire
The earliest role the company ever opened per function — a new budget line. Optional function narrows it to one. Values are provenance-wrapped.
{
"by_function": {
"sales": { "value": "2026-03-14T09:00:00Z", "source_board": "role-slot", "observed_at": "2026-03-14T09:00:00Z", "confidence": 1.0 }
},
"as_of": "2026-07-08T04:11:07Z"
}
Repost pain
GET /v1/companies/{company_id}/repost-pain
Reqs the company keeps failing to fill, hardest first. repost_count is provenance-wrapped.
{
"reqs": [
{
"title": "Staff SRE",
"function": "engineering",
"country": "US",
"first_seen": "2026-01-08T12:00:00Z",
"repost_count": { "value": 4, "source_board": "greenhouse", "observed_at": "2026-07-01T06:00:00Z", "confidence": 0.9 }
}
],
"as_of": "2026-07-08T04:11:07Z"
}
ATS migrations
GET /v1/companies/{company_id}/ats-migrations
Applicant-tracking vendor switches, with a provenance-wrapped occurred_at.
{
"migrations": [
{
"from_vendor": "lever",
"to_vendor": "greenhouse",
"occurred_at": { "value": "2026-02-11T00:00:00Z", "source_board": "greenhouse", "observed_at": "2026-02-11T00:00:00Z", "confidence": 0.8 }
}
],
"as_of": "2026-07-08T04:11:07Z"
}
Who is hiring for a role?
GET /v1/reqs/search
The reverse lookup: companies with active reqs matching a role and geography, deduped by company, each with its full hiring pulse and the specific reqs that matched.
| Query parameter | Default | Notes |
|---|---|---|
role | — | Matches the req’s function |
geo | — | Matches the req’s country |
since | — | Only reqs first seen at or after this timestamp |
cursor | — | Keyset cursor; pass the previous page’s next_cursor |
limit | 20 | Page size |
{
"companies": [
{ "company_id": 4412, "pulse": { "…": "…" }, "matched_reqs": [ { "…": "…" } ] }
],
"next_cursor": 4412,
"as_of": "2026-07-08T04:11:07Z"
}
Paginate by passing next_cursor back as cursor until it comes back null.
Search open jobs
GET /v1/jobs/search
The flat, role-granular counterpart to who is hiring for a role: the individual open roles across companies matching a role and geography, one row per logical req (each with its own company_id and req_key) rather than grouped by company. ATS-only and freshness-floored, like every Tier 0 read.
| Query parameter | Default | Notes |
|---|---|---|
role | — | Matches the req’s function |
geo | — | Matches the req’s country |
since | — | Only reqs first seen at or after this timestamp |
cursor | — | Keyset cursor; pass the previous page’s next_cursor |
limit | 20 | Page size |
{
"jobs": [
{
"req_key": "…",
"company_id": 4412,
"title": "Senior Backend Engineer",
"function": "engineering",
"country": "DE",
"first_seen": "2026-06-02T10:00:00Z",
"board": "greenhouse"
}
],
"next_cursor": "4412:…",
"as_of": "2026-07-08T04:11:07Z"
}
Paginate by passing next_cursor back as cursor until it comes back null. Unlike /v1/reqs/search, whose cursor is a numeric company id, this cursor is an opaque string — treat it as a token and don’t parse it.
Market role demand
GET /v1/markets/role-demand
Market-wide (not company-scoped) active-requisition demand over time, for a role and geography.
| Query parameter | Notes |
|---|---|
role | Matches the req’s function |
geo | Matches the req’s country |
since | Lower bound on the series |
{
"series": [ { "bucket": "2026-06-01T00:00:00Z", "active_reqs": 318 } ],
"as_of": "2026-07-08T04:11:07Z"
}
Tier 1 — freshness-aware reads
Both routes below accept max_age (seconds) and are dual-metered against it. Omit max_age and you get the best available data, billed cached. See the Tier 1 table for exactly how each case bills, including the 202 cold-tail response.
Hiring pulse
GET /v1/companies/{company_id}/hiring-pulse?max_age=3600
Velocity, direction and momentum in one call. The six headline values are provenance-wrapped; direction is one of up, down, flat. is_surge is true when the company has crossed both the new-roles and velocity thresholds.
{
"is_hiring": { "value": true, "source_board": null, "observed_at": "…", "confidence": null },
"open_req_count": { "value": 7, "source_board": null, "observed_at": "…", "confidence": null },
"new_roles_30d": { "value": 3, "source_board": null, "observed_at": "…", "confidence": null },
"velocity": { "value": 0.1, "source_board": null, "observed_at": "…", "confidence": null },
"direction": { "value": "up", "source_board": null, "observed_at": "…", "confidence": null },
"is_surge": { "value": false,"source_board": null, "observed_at": "…", "confidence": null },
"by_function": { "engineering": 5, "sales": 2 },
"momentum": [ { "bucket_start": "2026-06-01T00:00:00Z", "open_req_count": 5 } ],
"as_of": "2026-07-08T04:11:07Z"
}
When the company is cold and you declared a max_age, you get 202 instead:
{ "job_id": 91823, "status": "crawling" }
Pre-action brief
GET /v1/companies/{company_id}/pre-action-brief?max_age=3600
Everything an agent needs before acting on a company, pre-joined into one round-trip instead of five: the pulse, first hires, repost pain, top open reqs, and ATS migrations. Built to be token-shaped — every list is capped (top reqs 5, repost pain 5, momentum points 8, migrations 3) regardless of how much history a company has.
Per-field provenance envelopes are stripped here — carrying one on every field would blow the token budget this endpoint exists to bound — and replaced by a single _provenance_summary rollup.
{
"pulse": { "is_hiring": true, "open_req_count": 7, "new_roles_30d": 3, "velocity": 0.1,
"direction": "up", "is_surge": false, "by_function": {"engineering": 5},
"momentum": [ { "bucket_start": "…", "open_req_count": 5 } ] },
"first_hires": { "sales": "2026-03-14T09:00:00Z" },
"repost_pain": [ { "title": "Staff SRE", "function": "engineering", "country": "US",
"first_seen": "…", "repost_count": 4 } ],
"top_reqs": [ { "title": "Senior Backend Engineer", "function": "engineering",
"country": "DE", "first_seen": "…", "boards": ["greenhouse"] } ],
"ats_migrations": [ { "from_vendor": "lever", "to_vendor": "greenhouse", "occurred_at": "…" } ],
"_provenance_summary": { "as_of": "2026-07-08T04:11:07Z", "source_boards": ["greenhouse", "role-slot"] }
}
Cold behaves exactly as hiring-pulse: 202 {"job_id": …, "status": "crawling"}.
The change feed
Poll for changes
GET /v1/events?since={cursor}
The ledger’s diff feed: every event with event_seq greater than since, oldest first, plus a next_cursor to replay from. This is the replacement for re-scraping — you ask what changed, not what exists.
| Query parameter | Default | Notes |
|---|---|---|
since | (required) | Return events after this event_seq. Start at 0 |
company_id | — | Only this company’s events |
event_type | — | One of opened, reposted, reobserved |
limit | 100 | Page size |
curl -H "X-API-Key: $PLANE_API_KEY" "$PLANE_BASE_URL/v1/events?since=0&limit=2"
{
"events": [
{
"event_seq": 1001,
"company_id": 4412,
"req_key": "greenhouse:zollsoft:senior-backend-engineer",
"board": "greenhouse",
"event_type": "opened",
"observed_at": "2026-07-08T04:11:07Z",
"function": "engineering",
"country": "DE",
"title": "Senior Backend Engineer",
"source_board": "greenhouse",
"confidence": 0.94
}
],
"next_cursor": 1001
}
Store next_cursor and pass it as the next since. The feed bills one change unit per event returned, independent of poll count — an empty page costs nothing, so poll as tightly as you like.
Register a webhook
POST /v1/webhooks
| Field | Notes |
|---|---|
url | Where deliveries are POSTed |
secret | Your HMAC signing key. Stored for signing, never logged, never returned |
{ "id": 7, "url": "https://example.com/hooks/signalsapi" }
Returns 201.
Watch a company
POST /v1/watches
Subscribe a registered webhook to one company’s hiring events. Bills one watch unit.
| Field | Notes |
|---|---|
company_id | The company to watch |
event_types | Any of opened, reposted, reobserved |
webhook_endpoint_id | An id from POST /v1/webhooks, belonging to your account |
Returns 201 {"id": 15}, or 404 when the webhook endpoint isn’t yours.
Cancel a watch
DELETE /v1/watches/{watch_id}
Returns 204, or 404 when the watch isn’t yours.
Writing back
Record an outcome
POST /v1/companies/{company_id}/outcomes
Tell the plane what happened after you acted — the substrate for outcome-aware scoring.
| Field | Notes |
|---|---|
outcome | Your outcome label |
observed_at | When it happened |
req_key | Optional — ties the outcome to a specific requisition |
Returns 202 {"id": 88}.
Clay enrichment
POST /v1/clay/enrich
Documented separately, with setup steps, on the Clay integration page.
Notes & limits
- Your data only. Every route filters by the authenticated key’s
customer_id. Another customer’s key id, watch id or webhook id returns404, never their data. - Keys are shown once. Only a hash is stored. Lose it and you mint a new one.
- Cold reads never block. A
202queues a priority crawl and returns ajob_idimmediately; the request thread is never held open on a live crawl. - The change feed is the cheap path. Polling
/v1/eventscosts nothing when nothing changed — prefer it over re-reading company endpoints on a timer.
See Limits for every pagination default and rate limit stated across the API in one place.
Recent changes
- 2026-07-09 — Agent data plane REST API published: the full /v1 reference for every endpoint, request, and response shape.