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

Events

Follow durable organization events over a resumable server-sent events stream.

GET /api/v1/events is the agent event stream. The app endpoint answers 308 with ${ENGINE_PUBLIC_URL}/api/v1/events in Location and in the body's destination, and the redirect preserves the query string. The engine holds the long-lived connection and requires an API key with the events:read scope.

The engine is a different origin from the app, so follow the redirect with --location-trusted, which keeps the Authorization header across the hop. Plain -L drops it and the stream answers 401 with error: "unauthorized". What that trust is worth is bounded by the app: the destination is the engine origin the deployment configures, and the app refuses to send a key anywhere insecure — anything but https, or a loopback host in local development, is answered 503 with error: "events_stream_unavailable" instead of a redirect. The hop that carries your key is encrypted by construction.

curl -N --location-trusted https://www.spore.ee/api/v1/events \
  -H "Authorization: Bearer $SPORE_API_KEY"

A browser cannot hold this stream itself. An EventSource sends no Authorization header at all, and the engine origin sends no CORS headers, so a page's own fetch cannot follow the redirect either. Keep the key on your server, subscribe there, and forward to the page what it needs.

The stream is server-sent events. Each durable row is delivered as:

id: 1842
event: run.completed
data: {"kind":"run.completed","payload":{"jobId":"job_123","executionId":"run_456","status":"completed","workflowName":"domain-finder"},"createdAt":"2026-08-16T12:00:00.000Z"}

id is the monotonic org_event.id. event is the event kind; the JSON data object contains that same kind, the event-specific payload, and its createdAt timestamp. A connection also receives comment-only connection and heartbeat frames, which clients may ignore.

Resume after a disconnect

Persist the last delivered id and send it as Last-Event-ID when reconnecting. The engine replays rows with a greater id, as far back as the seven-day replay reach:

curl -N --location-trusted https://www.spore.ee/api/v1/events \
  -H "Authorization: Bearer $SPORE_API_KEY" \
  -H "Last-Event-ID: 1842"

If the cursor is older than that reach, the first frame is an event named resync_required:

id: 1842
event: resync_required
data: {"kind":"resync_required","payload":{"kind":"resync_required","oldestId":"1750","reason":"last_event_id_older_than_retention"}}

reason reads last_event_id_older_than_retention for historical reasons: the bound it refers to is the seven-day replay reach, not the ninety-day retention below. The value is stable and safe to match on; your acts are not deleted when you receive this frame.

Treat that frame as a signal to reconcile from the durable REST reads, then reconnect from the newest cursor. A malformed or negative cursor is rejected with 400.

Event kinds and payloads

Writers append lifecycle events in the same transaction as the mutation they describe. Every job.* payload contains jobId. Every run.* payload contains the joinable identity pair jobId and executionId; executionId is the v1 wire name for the Run ID and remains stable across Attempts.

Job lifecycle

The complete Job family is:

  • job.created: kind, intent, targetCount, and priority.
  • job.queued: queuedAt.
  • job.started: startedAt.
  • job.cancelling: cancelRequestedAt.
  • job.completed: completedAt, errorCode: null, lifecycle, and Run counts. lifecycle is completed when every Run succeeded, or completed_with_issues when any Run failed or was cancelled.
  • job.failed: completedAt, the coordinator errorCode, lifecycle: "failed", and Run counts. This means the parent orchestration failed; child Run failures alone are completed_with_issues.
  • job.cancelled: completedAt, errorCode: null, lifecycle: "cancelled", and Run counts.

A Job that ran over a Segment also carries its segmentId on job.completed, job.failed and job.cancelled, so the event that says the work ended names the Segment it ended over.

Run lifecycle

The complete Run family is:

  • run.waiting: the Run's workflowName (and any submission-specific fields).
  • run.queued: queuedAt.
  • run.started: workflowName and startedAt.
  • run.retrying: stepName, the errored attempt, and errorName.
  • run.progressed: stepName, its attempt, and its outcome (ok, soft_miss or error). One per step attempt that closes, written with the record of that attempt, so a Run announces each step as it finishes rather than only when it settles. A Run makes at most three attempts at each step it reaches, so it records at most 42 of these for the domain finder, 33 for the contact finder (whose per-page steps read at most three pages) and 18 for web research; a typical Run records about six. A step the engine replays after a crash reports its attempt again.
  • run.completed: status: "completed" and workflowName.
  • run.failed: status: "failed", workflowName, and errorCode.
  • run.cancelled: status: "cancelled" and workflowName.

Credit balance

balance.changed always contains amount, the resulting balance, transactionId, and the ledger type. Workflow reservation/charge and refund movements also contain the joinable jobId and executionId pair: executionId is the v1 wire name for the Run ID and remains stable across Attempts. Purchases, grants, and other movements without a Run omit both IDs.

Recorded Facts

fact.recorded: the companyId the Facts landed on and how many were recorded, never their values; read the Company's Facts for those. One per POST /api/v1/record call that records Facts about a Company, written with those Facts, so a refused call records none. A Run's Facts arrive with its run.* events instead.

Recorded Conclusions

conclusion.recorded: the segmentId the Conclusions landed on and how many were recorded, never their values; read the Segment's Conclusions for those. One per POST /api/v1/record call that records Conclusions about a Segment, written with those Conclusions, so a refused call records none.

Facts and Conclusions draw on one rolling daily allowance of 10,000 rows per organization, and every call that records lands at least one row of it, so an organization records at most 10,000 fact.recorded and conclusion.recorded events a day between them.

Delivery

Delivery follows the submitting principal, not whoever moved the work along. A key receives the events of the work it asked for, and the organization-wide kinds below whoever caused them; another key's work and another organization's rows are never visible. Unknown future kinds should be ignored rather than treated as a protocol failure.

Delivered to the key that submitted the Job: job.created, job.queued, job.started, job.cancelling, job.completed, job.failed, job.cancelled, run.waiting, run.queued, run.started, run.retrying, run.progressed, run.completed, run.failed, run.cancelled. The scheduler admits, the engine retries and settles, and a console user or an administrator may cancel; every one of those events still names the key whose submission created the Job.

Delivered to the key that asked for the export: export.completed and export.failed. A key-initiated export carries no payload.userId: the key acted, not its creator. A member-initiated export names that member in payload.userId, agreeing with the recorded act's actor on both delivery paths. An export of a Segment carries its segmentId on both kinds. Every export records export.completed, whichever way its bytes reach you, and the receipt says which way that was. An export answered inside the call carries delivery: "inline" and its bytes; its act is recorded as the response returns. A larger one carries delivery: "artifact" and is executed durably; its act is recorded when the artifact is stored, and carries the fileKey to fetch. export.failed belongs to the artifact path alone: an inline export that fails never becomes a receipt, so its failure is the call's own error rather than a later event. Exporting the recorded acts is itself an act, so an acts export records its own export.completed after freezing the rows it took: two identical whole-Collection acts exports differ by exactly that one row, the earlier export's.

Delivered to the key that recorded them: fact.recorded and conclusion.recorded.

Delivered to the key the change is about: key.policy_changed — that key's own creation, scope and budget changes, whoever made them, except the two changes below that end its own subscription.

That key is the act's subject; its presence alone does not identify the actor. The actor is whoever made the change. A change a member made from the console — a mint, a revocation, or an update to scopes, spend bounds or the Operation allowlist — names that member, so through GET /api/v1/audit/events the act's actor is {"kind":"member","userId":"<the acting member>","name":"<their display name>"} and its subject is {"apiKeyId":"<the key the change is about>"}; a frame the subject key receives carries that member's id as payload.userId. A change one key made to another through key-create or key-revoke names the key that acted: {"kind":"api-key","apiKeyId":"<the acting key>","name":"<its name>"}. The frame carries payload.actingApiKeyId, not the subject as actor. An act that recorded no principal names nobody — a system actor.

Delivered to every events:read key in the organization: balance.changed.

Recorded, not delivered

Two cases record an act no subscription can receive. Both are read through GET /api/v1/audit/events — the organization's recorded acts, described in Getting started — and in the console's governance events:

  • Anything a member started from the console, because no key submitted it. Its job.* and run.* rows carry no key, and neither does the export.completed of an export a member asked for. Who those rows name instead is below.
  • key.policy_changed about a key that can no longer subscribe. Two changes do that to their own subject: a revocation, after which the key cannot authenticate at all, and an update that takes events:read away, after which GET /api/v1/events refuses it with 403 and error: "scope_required". A revocation is not always asked for on the key surface. A key is also revoked when the member who created it loses their membership, and when that user is banned; both record the same kind, with action: "revoked" and the key as the subject. Each of those acts names the principal behind it: the owner or administrator who removed the member, the platform administrator who banned the user, or the platform itself when the removal or the ban arrived through a path that carries no principal at all. Removal records userId, a platform ban records adminId, and a requestless action has no principal, so the acts reader returns system. At revocation, the key's revoked_by names that same human principal, or remains empty for a system act. The policy detail exposes revocationCause: member_removed for a departure, user_banned for a ban, or key_revoked for an ordinary revocation on the key surface. Retained acts that never recorded a cause omit this field; absence does not imply an ordinary revocation.

Which console rows name the member

A key names itself on every row of the work it submitted. Work a member started from the console has no key, so each of its rows names the member who asked, or nobody at all. A console key.policy_changed names the member who made the change, while its row key is the subject, as Delivery above states. Which half a kind is in is a question about the row, not about when it is written: a settle step reached long after the request still names the member when the receipt it settles carries them. The two halves are exactly:

  • Naming the member who asked: job.created, job.queued, run.waiting, run.queued, export.completed, export.failed and key.policy_changed.
  • Naming nobody: job.started, run.started, run.retrying, run.progressed, run.completed, run.failed, run.cancelled, job.cancelling, job.completed, job.failed and job.cancelled. These are the engine's transitions, from the moment it picks the work up to the moment it settles it. A cancellation somebody else asked for is nobody's too.

Read an actorless row as "this happened to a member's work", not as "the platform did this": the system actor on it means no principal asked for that transition, not that the platform started the work.

Not on this stream

organization.limits_changed, organization.admission_changed, organization.suspended, and organization.resumed are recorded as organization activity an owner or administrator reads in the console, under organization General > Limits for the current caps and pause state. A Harness sees a limit only when a request is refused, through the 429 body and headers described in Conventions.

organization.suspended and organization.resumed carry the platform admin who acted, the reason recorded with the act, and whether cancelling the organization's running Jobs was signalled successfully. Those facts are readable through GET /api/v1/audit/events: the act's actor is {"kind":"member","userId":"<the acting admin>","name":"<their display name>"}, and its detail is {"of":"suspension","reason":"<the admin's own words>","cancellationSignalling":null}. reason is null when the act was recorded without one; cancellationSignalling is "failed" when the Suspension committed but signalling the queue threw, which means the refusal is in force while the running work may not have stopped. detail itself is null only when neither fact was recorded. The console's Audit ledger view shows the same facts in the organization's governance chronology.

Every act's actor names its principal the same way. name is the principal's current display name — a member's, or an API key's — read when the page is served rather than frozen into the act, so a member who has since renamed themselves reads back under the name you would recognise. It is null exactly when the principal no longer exists, and userId or apiKeyId is always there, so a row can be labelled by its id instead. A system actor has no name: the platform acted on its own schedule.

Connections, replay reach, and retention

At most 20 concurrent connections per organization are accepted. The 21st connection receives 429 with error: "too_many_connections"; close an idle stream before opening another.

Two different numbers govern how far back you can read:

  • Replay reach — 7 days. A reconnecting stream resumes from a Last-Event-ID up to seven days old; an older cursor is answered resync_required. This bounds resumption only; nothing is deleted at seven days.
  • Retention — 90 days. Recorded acts stay readable for ninety days through GET /api/v1/audit/events (see Getting started), and the calls that produced them stay in the ledger for the same ninety. After that they are deleted.

The calls ledger, readable through GET /api/v1/audit/calls and its Export, retains at most 10 401 samples per organization per rolling minute for known revoked or expired credentials. Further refused attempts do not add rows or produce 429 ledger rows: each increments suppressedAttempts on the latest retained 401 sample. For example, 30 refused attempts in one minute leave ten samples with a total of 20 suppressed attempts; 300 leave ten samples with a total of 290. The bound and increments are atomic in PostgreSQL, shared across instances, and remain effective when Redis is unavailable; this credential-refusal path does not require Redis.

suppressedAttempts is a nonnegative number, zero when no attempts were suppressed after that sample. It counts suppressed attempts across the organization, including other revoked or expired keys, not attempts by the row's apiKeyId. Filtering calls by key therefore does not produce a per-key total of refused attempts. The row's other fields, including its timestamp, request ID, Operation and key, still describe the original sampled call. The count stays with that sample for the same ninety-day retention; suppressed attempts are not individual calls or acts on the event stream.

The stream reads the durable rows every five seconds; nothing else wakes it, so an event reaches a connected subscriber within five seconds of the change that recorded it.

Recipe: land your own research

Declare what you want to know, record what your harness observed, and have Spore hold its shape.

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.

On this page

Resume after a disconnectEvent kinds and payloadsJob lifecycleRun lifecycleCredit balanceRecorded FactsRecorded ConclusionsDeliveryRecorded, not deliveredWhich console rows name the memberNot on this streamConnections, replay reach, and retention