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, andpriority.job.queued:queuedAt.job.started:startedAt.job.cancelling:cancelRequestedAt.job.completed:completedAt,errorCode: null,lifecycle, and Run counts.lifecycleiscompletedwhen every Run succeeded, orcompleted_with_issueswhen any Run failed or was cancelled.job.failed:completedAt, the coordinatorerrorCode,lifecycle: "failed", and Run counts. This means the parent orchestration failed; child Run failures alone arecompleted_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'sworkflowName(and any submission-specific fields).run.queued:queuedAt.run.started:workflowNameandstartedAt.run.retrying:stepName, the erroredattempt, anderrorName.run.progressed:stepName, itsattempt, and itsoutcome(ok,soft_missorerror). 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"andworkflowName.run.failed:status: "failed",workflowName, anderrorCode.run.cancelled:status: "cancelled"andworkflowName.
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.*andrun.*rows carry no key, and neither does theexport.completedof an export a member asked for. Who those rows name instead is below. key.policy_changedabout 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 takesevents:readaway, after whichGET /api/v1/eventsrefuses it with403anderror: "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, withaction: "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 recordsuserId, a platform ban recordsadminId, and a requestless action has no principal, so the acts reader returnssystem. At revocation, the key'srevoked_bynames that same human principal, or remains empty for a system act. The policy detail exposesrevocationCause:member_removedfor a departure,user_bannedfor a ban, orkey_revokedfor 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.failedandkey.policy_changed. - Naming nobody:
job.started,run.started,run.retrying,run.progressed,run.completed,run.failed,run.cancelled,job.cancelling,job.completed,job.failedandjob.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-IDup to seven days old; an older cursor is answeredresync_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.