Enrichment lifecycle
What a launch does after you start it: lifecycle, the five outcomes, polling, partial failure, cancellation, and automatic refunds.
Everything that costs compute is asynchronous. POST /api/v1/enrich answers with the
canonical Job resource — { job, quote, targeted, refused }. job is the authoritative
intent, scheduling, economics, capacity, and Run-progress projection. quote separates Run
compute from permanent Legal entity unlocks, and job.quotedCredits equals quote.total.
Keep job.id as the handle for GET /api/v1/enrichments/{enrichmentId}: a Job borrows the
ID of the act that created it, so the same ID reads the launch's own per-target projection.
Lifecycle and outcome
The poll body carries two independent fields:
lifecycledescribes where the run is:waiting | queued | running | completed | completed_with_errors | failed | cancelled.outcomedescribes what a terminal run produced:found | nothing_found | couldnt_look | failed | cancelled, ornullwhile it iswaiting,queued, orrunning.
The projection is a pure function of the stored run:
| Lifecycle | Outcome | Meaning |
|---|---|---|
waiting, queued, running | null | Admission or execution is still in progress |
completed | found | The tool looked and produced an answer |
completed | nothing_found | The tool looked and definitively found nothing |
completed | couldnt_look | The world stopped the tool from looking (an unreachable or blocked site, unreadable pages) |
failed | failed | Spore's fault: a thrown error, a supplier outage, or Spore's own cost cap or output ceiling |
cancelled | cancelled | Cancellation ended the run |
Do not collapse these fields into a single status. The accepted response's job.lifecycle
uses the customer Job vocabulary, while the preserved v1 launch.lifecycle and
launch.outcome describe launch progress; each child poll has its own lifecycle and
outcome.
Polling a launch
curl https://www.spore.ee/api/v1/enrichments/$LAUNCH_ID \
-H "Authorization: Bearer $SPORE_API_KEY"The response below shows the lifecycle fields an explicit-Company enrichment preserves. Enrichments now target Company ids directly; live Segment membership is read first and is not stored as a Segment target on the launch.
{
"executionId": "51e40960-d578-42c6-812c-7c2e21cfc540",
"workflow": "domain-finder",
"lifecycle": "completed",
"outcome": "nothing_found",
"message": "No candidate domains found.",
"input": {
"tool": "domain-finder",
"intent": "Find the domain for Bolt",
"input": {},
"priority": "normal",
"maxCredits": 2
},
"output": null,
"billing": {
"creditsCharged": 0
},
"startedAt": null,
"completedAt": "2026-08-12T09:27:59.265Z",
"createdAt": "2026-08-12T09:27:37.564Z",
"launch": {
"id": "51e40960-d578-42c6-812c-7c2e21cfc540",
"tool": "domain-finder",
"executor": "spore",
"launchKind": "full",
"priority": "normal",
"lifecycle": "completed",
"outcome": "nothing_found",
"targeted": 1,
"completed": 1,
"failed": 0,
"canceled": 0,
"cancelRequestedAt": null,
"error": null,
"createdAt": "2026-08-12T09:27:37.564Z"
}
}| Field | Meaning |
|---|---|
executionId | the id you polled |
workflow | the tool that ran |
lifecycle, outcome, message | where the run is and what it produced |
input | on a launch, the stored envelope whose nested input holds shared knobs and context references naming Catalog fields; on a child Run, those knobs plus server-written core and per-target resolved knowledge; targets are not stored in this envelope |
output | flat tool output, or null before completion and on non-success terminal states |
billing.creditsCharged | what this row was charged at trigger time |
startedAt, completedAt | null until they happen |
createdAt | when the row was written |
launch | present only on a launch row; per-target progress for the work it dispatched |
launch.priority | the scheduling tier, read from the canonical Job row |
job | the accepted POST /api/v1/enrich response; the canonical Job projection |
job.intent, job.kind | the durable purpose and workflow kind |
job.lifecycle | the customer Job lifecycle, including completed_with_issues |
job.priority | background, normal, or urgent, read from the Job row |
job.occupiedSlots | the Runs of this Job currently holding a slot |
job.targetCount, job.runs, job.runTotal | declared target count and aggregate Run counts |
job.quotedCredits, job.maxCredits | the Job's quote and hard credit ceiling |
quote.runs, quote.unlocks, quote.total | accepted POST Run compute, permanent Legal entity unlocks, and their total; job.quotedCredits equals quote.total |
targeted, refused | admitted target count and targets refused for missing Core; the Job fields are authoritative after admission |
Read the launch's frozen ordered target receipt at
GET /api/v1/jobs/{jobId}/targets. Its pages carry
{ items: [{ targetIndex, companyId }], nextCursor, watermarks: { postgres, generations } }.
watermarks.generations maps each Jurisdiction the Commons served at admission to the
Generation this Job read, as in { "EE": "20260905_023200" }, and is null when none was
named. That receipt and its watermarks do not change when a target is later deleted or merged.
Polls are free and set no Retry-After — see
the async pattern.
The launch block
targeted is the declaration: how many targets the launch was created for. completed,
failed and canceled are derived from the children that have arrived, never from a
stored counter — so a launch nobody is working on simply shows no progress rather than a
comforting lie.
For Spore-executed launches, each admitted Company target has one child row. A launch's
lifecycle and outcome summarize those child rows.
A launch goes terminal when every child has settled and either the target count was reached or cancellation was requested. Its terminal lifecycle is then:
| Children | launch.lifecycle | launch.outcome |
|---|---|---|
| at least one completed and one failed | completed_with_errors | failed |
| any failed, but none completed | failed | failed |
| otherwise, any cancelled or fewer children than targeted | cancelled | cancelled |
| otherwise | completed | found if any child found, else nothing_found if any found nothing, else couldnt_look |
The launch's own projection spells the mixed terminal state completed_with_errors. The
canonical job.lifecycle calls the same aggregate state completed_with_issues; use the Job
field when deciding whether the intent completed cleanly. A child settles with completed,
failed, or cancelled lifecycle.
Reading the results
The launch is progress, not payload. Each child holds its own run, and each child is a pollable enrichment in its own right:
{
"executionId": "ad3c513b-67c8-43a1-b65e-455dca4be04b",
"workflow": "domain-finder",
"lifecycle": "completed",
"outcome": "found",
"message": "Completed successfully.",
"input": {
"core": {
"companyId": "8a7a0f13-fce0-4d97-a5f8-1950a49e7a63",
"name": "Bolt Technology OÜ",
"country": "EE",
"activity": {
"code": "62.01",
"label": "Ride-hailing and micromobility platform"
},
"identifier": "12417834"
},
"knowledge": []
},
"output": {
"domain": "bolt.eu",
"confidence": "high",
"responsive": true,
"linkedin": {
"url": "https://www.linkedin.com/company/bolt-eu",
"confidence": "high"
}
},
"billing": {
"creditsCharged": 2
},
"startedAt": "2026-08-12T09:27:38.847Z",
"completedAt": "2026-08-12T09:27:59.252Z",
"createdAt": "2026-08-12T09:27:38.694Z"
}output above is abbreviated: the real domain-finder body also carries the enriched domain,
bot-protection details, the Company's brand name, and activity-match percentage. No launch
key, because a child is not a launch.
You do not have to keep the child ids. The work lands as knowledge on the company, so the
durable read is the dossier: GET /api/v1/company?ids={companyId} returns the company
projection and its knowledge fields. Each value carries observedAt, stale, and
sourceRefs — the enrichment ids that produced it, which you can poll for the full run.
Nothing found and couldn't look
A tool that looked and definitively found nothing settles with lifecycle: "completed" and
outcome: "nothing_found". A tool the world stopped from looking — the site was unreachable
or blocked, or its pages could not be read — settles completed with
outcome: "couldnt_look". Either way the tool's own reason becomes message, rather than a
generic "no result".
Nothing found stays charged in full: "we looked, there is nothing" is a usable answer, and paying for it once is cheaper than paying to re-ask. Couldn't look refunds the part no provider work consumed.
Refunds
Credits are charged by the child's first durable workflow step. What a child keeps follows from its outcome alone, refunded automatically and idempotently:
| Outcome | Charge |
|---|---|
found | kept in full |
nothing_found | kept in full |
couldnt_look | refunds the part no provider work consumed |
failed | refunds everything still held — Spore's fault costs you nothing |
cancelled | refunds the part no provider work consumed; a child cancelled while still queued refunds in full |
A refund is a positive workflow_refund row in GET /api/v1/wallet/transactions
referencing the enrichment it returned, so a batch that broke halfway is auditable from the
ledger alone.
Cancelling
curl -X POST https://www.spore.ee/api/v1/enrichments/$LAUNCH_ID/cancel \
-H "Authorization: Bearer $SPORE_API_KEY" \
-H "Idempotency-Key: cancel-$LAUNCH_ID-1"Cancelling is free and answers 200 with { "launch": … } — the same launch resource the
poll returns. A Spore launch records the request and in-flight children observe it on their
next checkpoint. Pending children are refunded per the rules above.
A repeat cancel on a settled cancelled launch answers 200 with { "launch": … },
even with a new Idempotency-Key. This is idempotent: the requested cancellation has
already succeeded. It returns the unchanged launch, including its original
cancelRequestedAt, rather than requesting cancellation again.
A settled launch whose lifecycle is not cancelled answers 409 with
already_terminal: its lifecycle is completed, completed_with_errors, or failed.
Cancellation may have been requested and some children may have been cancelled;
child failures take precedence when the launch settles. This code never belongs
to a launch that has not settled.
Reusing the same Idempotency-Key and input after a successful cancellation
replays its stored answer.
A later retry after a refused cancel is evaluated again, even with the same key and input: a refusal does not retain the idempotency claim. If the launch has moved, the answer can change.
{
"error": "already_terminal",
"message": "Execution is already completed.",
"current": "completed"
}A repeat cancel on a launch whose cancellation is already requested and not yet settled is
409 cancel_already_requested. Its current is the launch's own lifecycle, which is still
non-terminal — that is the point of the separate code:
{
"error": "cancel_already_requested",
"message": "Cancellation is already requested for this execution; poll it until its lifecycle is terminal.",
"current": "pending"
}A launch that never had a cancellation requested and has no cancellable work left — its
children have stopped, its own settlement has not landed yet — is 409 nothing_cancellable.
Both answers mean the same next step: keep polling until the lifecycle is terminal.
A launch whose executor cannot be cancelled answers 409 with
executor_not_cancellable. An id that is absent — or belongs to another organization —
is a 404.
A cancel that is recorded but cannot yet reach every running child answers 503
service_unavailable with retryAfter (and Retry-After). The launch is not cancelled
yet, so there is no launch in the answer. Retry after the wait, with the same
Idempotency-Key or a new one: the retry itself performs the rest of the cancel and
answers 200 with the launch, as a cancel that reached every child does, or
409 already_terminal if the launch settled first. Nothing else finishes it for you.