Documentation
Documentation

Overview

Spore docs

Start here

Getting startedInstall the CLIAuthentication and keys

Build

Search and filtersSegments and the loopEnrichment lifecycle

Recipes

Recipe: search to a live SegmentRecipe: enrich without a segmentRecipe: land your own research

Connect

Events

Account

Credits and billing

Reference

Conventions
API reference

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

StatusMeaning
400Malformed 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)
401Missing or invalid API key
402Insufficient credits, a spend-bound refusal, or max_credits_exceeded
403Missing capability scope, or a plan/namespace rule forbids the operation
404Absent or belongs to another organization — existence is never leaked
409Current-state conflict: stale Spore-If-Match, idempotency_conflict, a filter/version conflict, or a cancellation refusal
410The referenced SearchResult or other resource has expired and cannot be used
422Input validation failed, including malformed UUID/path parameters; operation-specific validation names the refusal
428A required Spore-If-Match precondition was not supplied
429Organization rate, concurrency, admission cap, admission pause, or connection cap
500Unexpected server error
503Service temporarily unavailable — a dependency is down or too slow for the decision budget
504Upstream 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.

PlanRequests/minuteConcurrent RunsAdmitted Jobs
Free6001100
Starter1,2005500
Pro3,000252,500
Enterprise6,000505,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:

HeaderValue
Retry-Afterseconds to wait
X-RateLimit-Limitthe limiting window's capacity
X-RateLimit-Remainingremaining requests, usually 0 on refusal
X-RateLimit-ResetUnix 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.

Credits and billing

What each operation costs, what is free, how variable prices are quoted before they are charged, and how to read the ledger.

API reference

Every public Spore operation, generated from the contract registry the server itself validates against.

On this page

One key, one headerThe error envelopeStatusesOrganization LimitsCursor paginationETagsIdempotencyThe async patternOperation versions and sunset