Conventions
The envelope, lifecycle, outcomes, rate limit, cursors, ETags, idempotency, and the async pattern — everything that is true of every operation.
Every /api/v1 operation shares the rules on this page. They are stated here once so no
guide has to restate them, and so an agent can learn them before it has met an endpoint
that uses them.
One key, one header
Every /api/v1 call authenticates with Authorization: Bearer spore_sk_...,
and a key belongs to an organization rather than a person. Capability scopes, spend bounds,
and the refusals a bounded key produces are in
Authentication and keys.
The error envelope
Every non-2xx body has the same top-level shape:
{
"error": "validation_error",
"message": "Invalid input",
"details": [
{
"field": "filter",
"message": "Value is not valid for this field",
"code": "invalid_value",
"operator": "in"
}
]
}error is the machine-stable token — branch on it. message is prose for a human and is
not a contract surface: it may be reworded without a version bump.
details is present on field-level validation failures. Its usual shape is an array of
{ field, message, code?, operator? }; code and operator are preserved when the
validator supplied them, and you must not assume codes are uppercase. Some domain
refusals use a single operation-specific details object instead (for example, a node
rule refusal). Structured refusal quantities are top-level fields beside the envelope:
required, available, bound, requiredCredits, maxCredits, count,
periodDays, current, currentEtag, currentVersion, expected, received,
field, and operator appear only where they apply. The OpenAPI document
is generated from the shared response contract.
Statuses
| Status | Meaning |
|---|---|
400 | Malformed JSON/body, a missing required Idempotency-Key on a write, or a write carrying a standard conditional header such as If-Match (conditional_header_unsupported) |
401 | Missing or invalid API key |
402 | Insufficient credits, a spend-bound refusal, or max_credits_exceeded |
403 | Missing capability scope, or a plan/namespace rule forbids the operation |
404 | Absent or belongs to another organization — existence is never leaked |
409 | Current-state conflict: stale Spore-If-Match, idempotency_conflict, a filter/version conflict, or a cancellation refusal |
410 | The referenced SearchResult or other resource has expired and cannot be used |
422 | Input validation failed, including malformed UUID/path parameters; operation-specific validation names the refusal |
428 | A required Spore-If-Match precondition was not supplied |
429 | Organization rate, concurrency, admission cap, admission pause, or connection cap |
500 | Unexpected server error |
503 | Service temporarily unavailable — a dependency is down or too slow for the decision budget |
504 | Upstream timed out |
There is no 412 response in this contract: a stale Spore-If-Match is a 409, while a
missing Spore-If-Match is a 428.
404 rather than 403 for another organization's resource is deliberate: a 403 would
confirm the id exists. Isolation is a read of your own organization, not a permission
check on someone else's.
Organization Limits
Requests share one sliding 60-second window per organization across REST and durable submissions from the console. Rotating API keys does not create a new budget. A transport request that creates a Job consumes the window once, not again when its durable rows are inserted.
| Plan | Requests/minute | Concurrent Runs | Admitted Jobs |
|---|---|---|---|
| Free | 600 | 1 | 100 |
| Starter | 1,200 | 5 | 500 |
| Pro | 3,000 | 25 | 2,500 |
| Enterprise | 6,000 | 50 | 5,000 |
These are defaults from the active plan. A cap of -1 means unbounded, the
same word the plan catalog uses for members, Segments, and Companies: nothing
is counted and nothing is refused by that cap, and every surface shows it as
"Unbounded" rather than as the number. Platform administrators can override
individual positive-integer caps; the console shows defaults, overrides, and
effective values. Changes are recorded in organization activity. An admitted
Job is an unfinished Job, including its waiting work.
Owners and administrators find pause/resume under organization General > Limits. Platform administrators manage overrides under Admin > Organizations > Manage limits.
The request that overruns the rate window is refused with 429:
{
"error": "rate_limited",
"message": "Rate limit exceeded. Please retry later."
}It carries four headers:
| Header | Value |
|---|---|
Retry-After | seconds to wait |
X-RateLimit-Limit | the limiting window's capacity |
X-RateLimit-Remaining | remaining requests, usually 0 on refusal |
X-RateLimit-Reset | Unix seconds at which the limiting window clears |
The transport door fails closed when the rate-limit store is unavailable or healthy but slow: even a valid organization credential receives 503 service_unavailable if the rate-limit decision cannot finish within its 1000 ms total deadline. The budget includes the held-cap lookup, its durable fallback, and the window check. An earlier error can refuse sooner. Authentication, refusal recording, network transit, and event-loop scheduling add to this budget; it is not a whole-request timeout. Retry-After: 5 on this refusal means wait five seconds after receiving the response before retrying, not that the server waits five seconds before refusing. Recovery is not guaranteed then. An identified but refused organization credential (expired or revoked) does not consult Redis. PostgreSQL atomically bounds and records its refusals across API processes, retaining at most 10 401 samples per organization per rolling 60 seconds. Sampled refusals retain 401 key_expired or key_revoked without Retry-After or rate-limit headers. Further refusals increment suppressedAttempts on the latest sample instead of creating a ledger row, and receive 429 with Retry-After and rate-limit headers. Retry-After identifies when the oldest sample leaves the window; suppressed attempts do not extend it. There is no Redis-check deadline or per-process fallback on this path: an unavailable or nonresponding Redis adds no wait, while authentication, PostgreSQL lock and query time, transit, and scheduling still determine response latency. The 1000 ms rate-limit decision deadline is not a PostgreSQL or whole-request timeout. An explicitly unbounded request cap needs no organization window, but resolving that cap remains subject to the decision deadline.
A refused credential still follows authentication refusal handling; the rate-limit-store outage does not make it valid or admit its Operation.
The rate-limit store is not the only store behind the door. Every read of the Commons is served from a public Elasticsearch cluster, which has a budget of its own and two refusals rather than one:
For every Operation that declares 503 and reads the public cluster, an unreachable or timed-out cluster is a dependency outage: 503 commons_unavailable when the Operation explicitly refuses unavailable Commons, or 503 service_unavailable when the transport failure is projected at the door. POST /api/v1/search uses service_unavailable; search unlock, coverage, enrich, Company and Segment-member reads can explicitly refuse commons_unavailable. Both carry retryAfter: 5 in the error envelope and Retry-After: 5, derived only from that envelope field. Wait 5 seconds after receiving the response before retrying; recovery is not guaranteed then. Each public-cluster read is one attempt with no client retries, bounded at 5000 ms by PUBLIC_CLUSTER_READ_DEADLINE_MS. An earlier connection error refuses sooner. This is a per-read budget, not a whole-request timeout: multiple reads can each consume that bound, and authentication, refusal recording, network transit, and event-loop scheduling add to it. The public-cluster budget is separate from the 1000 ms rate-limit decision deadline. This 503 contract does not change Company responses that expose an unavailable section as state: unavailable rather than refusing the request.
New durable Jobs are also refused with 429 concurrency_limit when queued or
running Runs occupy the concurrency cap, or 429 admission_limit when the
unfinished Job cap is reached. These checks and the Job insert serialize on the
organization row. A capacity refusal carries Retry-After in seconds;
retrying after that interval is not a reservation and capacity may still be
occupied. A refusal by the request window carries all four headers above,
whether the transport door or a durable submission decided it.
An organization owner or administrator can pause admission without revoking
keys. New Jobs receive 429 admission_paused; reads and existing work remain
accessible. Already-admitted queued/running Runs may finish, while waiting Runs
remain waiting until admission resumes. Reducing a cap never cancels or removes
existing Jobs. Resume wakes the scheduler, which admits waiting Runs only when
effective concurrency has room. Replaying an accepted Job does not admit a new
Job. A refused write releases its idempotency claim so the same request may be
retried after recovery.
Cursor pagination
Paged reads take limit and cursor and answer with nextCursor. limit defaults to
50 and is clamped at 100. The cursor is opaque — do not parse it, decode it, or
construct one. Echo nextCursor back on the next request until it comes back null,
which is the only end-of-pages signal.
ETags
GET /api/v1/segments/{segmentId} returns a strong ETag. Send it as Spore-If-Match when
calling PATCH /api/v1/segments/{segmentId} to rename the Segment or replace its live
filter. A missing header is 428 precondition_required; a stale value is a 409 that
carries the current Segment and currentEtag (and sends the current ETag header), so you
can re-apply the edit without another read.
Not the standard If-Match: the CDN in front of the API answers If-Match itself, may do the
same with the other conditional headers, and would report a write that applied as failed. A write carrying If-Match,
If-None-Match, If-Modified-Since, If-Unmodified-Since or If-Range is refused
400 conditional_header_unsupported before anything changes. Send the body's etag: a
compressed answer's ETag header can arrive weakened (W/"1"), and a weak value never matches.
GET /api/v1/catalog supports If-None-Match; an unchanged vocabulary answers 304 with
no body.
Idempotency
Every /api/v1 write requires an Idempotency-Key, including free writes, enrich
dry-run quotes, Segment edits, and cancellation. When a write retains a replayable
response, reuse the same key with the same parsed input to replay it. Reusing a
claimed key with different input is 409 idempotency_conflict. Use a fresh key
for a distinct request. External knowledge lands through POST /api/v1/record.
A settled refusal that changed nothing does not retain its idempotency claim. A later retry is evaluated against the current state. An in-flight refusal is different: the attempt still owns its key and may yet make the intended change.
A write still waiting on the operations queue is refused after a 1000 ms dispatch wait with 409 idempotency_in_flight and Retry-After: 30. A dispatched attempt keeps its existing 30-second result wait. The dispatch budget covers status polling, not authentication, enqueueing, database stalls, refusal recording, network transit, or event-loop delays; it is not a whole-request timeout. The attempt stays enqueued and may run when a dispatcher returns: this refusal does not cancel it or free its key. Retry the same key and body, never a fresh key for the same intended effect. Ordinary writes replay their recorded result; key-create still refuses a replay because its secret is revealed only once. Retry-After means wait that many seconds after receiving the response; recovery is not guaranteed then.
The async pattern
Anything that starts work answers immediately with a resource or a Job — a handle, not
the final result. Native enrichment starts with POST /api/v1/enrich; its accepted response
is the canonical job, and you poll job.id at GET /api/v1/enrichments/{enrichmentId}
until lifecycle is terminal, then branch on outcome. Segment membership is a
synchronous, free read through GET /api/v1/segments/{segmentId}/companies, which returns
the live matching set and its exact total.
Enrichment polls are free and set no Retry-After — there is no server-blessed cadence.
Pick your own; Spore's dashboard polls live batches every 3–5 seconds.
What lifecycle means, how outcomes settle, and what refunds itself is in Enrichment lifecycle.
Operation versions and sunset
Operation contracts grow additively. A breaking change gets a distinct versioned
name and path; existing names never resolve to a moving latest alias. Current
and deprecated versions remain separate entries in the OpenAPI document.
One exception was made in place, before the API had any outside consumer: conditional writes
moved from If-Match to Spore-If-Match (ADR-0062) without a new versioned name or a new
OPERATION_VERSION, because no published client or live production caller existed to break.
An Operation declaration may carry sunset: { deprecatedAt, at }, with both dates
in YYYY-MM-DD UTC form. Every response from that Operation carries Deprecation
as a Structured Field date (@ followed by Unix seconds), and Sunset as an
HTTP-date. This includes refusals, conditional reads, and idempotency replays.
The OpenAPI entry has deprecated: true, x-deprecated-at, and x-sunset, and
documents both headers on each response.
Deprecation does not disable execution. The Operation keeps working through the
notice period; retirement is an explicit removal, not an automatic clock cutoff.
No existing Operation is deprecated merely by publishing a new document revision.
OPERATION_VERSION is that document revision date, shared by the OpenAPI
document and the agent guide, not a version applied to every Operation.