Recording runs¶
POST /api/v1/runs is the heart of the integration: one HTTP call per
execution, from any tool that can speak HTTP.
Request¶
curl -X POST "$LUMATRACK_URL/api/v1/runs" \
-H "Authorization: Bearer $LUMATRACK_KEY" \
-H "Content-Type: application/json" \
-d '{
"automation": "os-patching",
"status": "success",
"executed_at": "2026-06-11T14:30:00Z",
"duration_seconds": 142,
"units": 240,
"source": "ansible",
"external_id": "tower-job-99412",
"metadata": {"hosts": 240}
}'
| Field | Required | Notes |
|---|---|---|
automation |
yes | The automation's slug. GET /api/v1/automations lists them. |
status |
no | success (default), failure, skipped or cancelled. A skipped run started and deliberately did no work; a cancelled one was interrupted before it finished. Neither earns value and neither is counted as a failure. See Runs that do no work. |
failure_reason |
no | Free-form reason, e.g. auth/credential on a failure, nothing to process on a skip, ci/timeout on a cancellation. Each status groups into its own reason Pareto, and all are filterable on the run list. 200 chars. |
executed_at |
no | ISO 8601. Defaults to now. Naive datetimes are treated as UTC. Timestamps more than a few minutes in the future are refused; frozen months are refused. |
duration_seconds |
no | Non-negative integer. |
units |
no | Records/items this execution processed (patched hosts, reminders sent). Non-negative integer, default 1. Recorded with the run today; per-unit valuation is coming and will multiply value by it. |
baseline_minutes_override |
no | The total manual minutes this one execution replaced. Overrides the automation's standard baseline for this run only. Non-negative decimal up to 999999.99; omit it to use the standard baseline. See Asserting a baseline per run. |
source |
no | The reporting system, e.g. ansible, n8n, github-actions. Shows up in the run list. |
external_id |
no, but send it | Your system's run or job id. Makes ingestion idempotent. |
manual |
no | true marks a real manual execution recorded as opportunity evidence (defaults to true on a measuring automation). Manual runs measure what the status quo costs; they never book savings, count toward your event budget, and cannot carry ai or usage. |
ai |
no | Token usage for AI runs: {"model", "input_tokens", "output_tokens", "cached_tokens", "cost"?}. LumaTrack prices it server-side; see AI workloads. |
metadata |
no | Arbitrary JSON kept with the run (token counts, hosts touched, ticket ids). Capped at 16 KB; store a reference, not a full payload or transcript. |
Responses¶
| Code | Meaning |
|---|---|
201 |
Recorded. |
200 |
Replay: this external_id already exists, with "deduplicated": true. A full-scope key gets the stored run; an ingest-only key gets an acknowledgement (id, held, external_id) and no stored evidence. |
202 |
Recorded but held: you are over the plan's monthly event cap. The run is stored and excluded from value math until you upgrade or the month resets. The body says so in warning. |
400 |
Something in the body is wrong, or executed_at falls in a frozen month. The error string names the exact field. |
401 |
Missing or invalid API key. |
404 |
No automation with that slug in your organization. |
429 |
Two distinct cases, told apart by the Retry-After header. With Retry-After: the org's per-minute rate limit; back off for that many seconds and retry (see Rate limits). Without it: past 3x the plan's monthly cap; the event was refused (with a durable record kept on our side); held events release when you upgrade. |
Held events are released automatically when capacity allows (an upgrade, or
the monthly reset). A released event keeps its original executed_at and
posts to that month; it counts against the event cap of the month it is
released in. If its month has frozen in the meantime, the frozen figures
stay as published and the event books a
closed-period correction instead:
held evidence is never dropped and never re-dated.
Every non-2xx body is {"error": "..."} with a message written for a
human.
Practices that pay off¶
Always send external_id. Delivery code retries; networks flake. With
an external_id, a duplicate POST is a no-op instead of double-counted
savings. Use the id your platform already has: the n8n execution id, the
Ansible Tower job id, the GitHub Actions run id.
Report failures. Wire your error path to send "status": "failure".
Failure runs subtract real cost and earn no savings, and the reliability
chart needs them to tell the truth. An integration that only reports
success produces a number nobody should sign.
Report skips and cancellations too. If your workflow can run and decide
there is nothing to do, send "status": "skipped" on that path rather than
success; if it can be interrupted part way, send "status": "cancelled".
Either one booked as a success invents savings that never happened, and
either booked as a failure tells your client an automation is broken when it
is merely idle or was stopped.
Let executed_at default for live events. Set it only when reporting
something that genuinely happened earlier. If the timestamp falls in a
frozen month you get a 400; resubmit without executed_at to land the
evidence in the current period, or use backfill for real
historical loads.
Put measurements in metadata. Token usage, hosts patched, tickets
closed. It is stored with the run and comes back on GET /api/v1/runs.
Asserting a baseline per run¶
Your automation carries one standard baseline: the manual minutes a typical execution replaces, agreed once and versioned. Some source systems know better than that on a per-execution basis. A ticket platform knows that a password reset historically took 6 minutes and a mailbox migration took 90. When yours does, send the real figure with the run:
curl -X POST "$LUMATRACK_URL/api/v1/runs" \
-H "Authorization: Bearer $LUMATRACK_KEY" \
-H "Content-Type: application/json" \
-d '{
"automation": "ticket-remediation",
"external_id": "INC-88412",
"baseline_minutes_override": 45,
"metadata": {"category": "mailbox-migration"}
}'
What it replaces. The number is the total manual minutes for that
execution: the whole task, start to finish, as a human would have done it. It
replaces both parts of the automation's standard baseline, the per-batch
minutes and the per-unit minutes, so units on an overridden run stays
volume information. It does not replace oversight: the run still nets the
review time the automation says it costs, because that is your assumption
about the automated world, not the source system's knowledge of the manual
one.
How it is priced. At the automation's effective hourly rate (its own rate, else its role's loaded rate, else your organization default), with the same conservatism factor the productivity lane always applies. If the automation decomposes its baseline into steps at several roles, the override prices at the single effective rate: an asserted total does not decompose into steps.
Where it shows up. On the run's detail page, labeled as reported by the source system, and on the per-run statement behind any month's labor figure. LumaTrack did not measure this number and never presents it as though it did.
Send it only where the system genuinely knows. A baseline your ticket platform derives from years of handle-time data is evidence. A number a workflow author typed into a node because it felt about right is the standard baseline with extra steps, and it belongs in the automation's assumptions where it gets an owner, a version history and an attestation date.
0 is a real assertion, meaning this execution replaced no manual work at
all, and books $0 for that run. Omit the field entirely to use the standard
baseline. A run whose asserted baseline is smaller than its oversight books
$0 rather than a negative.
On a failed, skipped or cancelled run the value is stored and returned but
earns nothing, exactly like units.
Reading runs back¶
curl -s "$LUMATRACK_URL/api/v1/runs?automation=os-patching&status=failure&since=2026-06-01T00:00:00Z&limit=50" \
-H "Authorization: Bearer $LUMATRACK_KEY"
Newest first. Filters: automation (slug), status, since (ISO 8601),
limit (default 100, max 500). To page past the window, pass
before=<id of the last run you received> (an opaque run_... id) and
repeat while the response's has_more field is true. Held runs are included and flagged, and each run
carries its metadata.
Runs that do no work¶
Two statuses cover an execution that produced nothing. Both earn no labor value and neither is ever counted as a failure.
Skipped¶
A skipped run started and deliberately did no work. The common shapes:
- an n8n IF/Filter branch short-circuited because the queue was empty
- a poller found no new tickets
- a CI job was gated out by a path filter or
if:condition - a batch job ran inside a maintenance window and stood down
Skipped runs are recorded as evidence. They are real volume, and their absence would make a schedule look quieter than it is. But they are held apart from both other statuses:
Cancelled¶
A cancelled run was interrupted before it finished:
- a CI job killed by a timeout, or superseded by a newer push
- an operator stopping a workflow mid-flight
- a container evicted, a queue drained during a deploy
The difference from a skip is the money. A skip did no work, so by default it costs nothing. A cancellation was doing work when it died, so by default it is charged.
How the four statuses compare¶
| Labor value | Counts as a failure | Per-run cost | Success rate | |
|---|---|---|---|---|
success |
yes | no | charged | numerator |
failure |
no | yes | charged | denominator |
skipped |
no | no | not charged by default | excluded by default |
cancelled |
no | no | charged by default | excluded by default |
Both of those are judgement calls, so each automation decides for itself (Automation → Reliability), with a pair of settings per status:
- Charge the per-run cost? Off for skips, on for cancellations. Turn skips on when starting the workflow costs money regardless: an n8n execution credit, a billed container start. Turn cancellations off when they are queued jobs killed before they ever started.
- Count against the SLA? Off for both, so success rate is successes over successes plus failures and neither is credit nor blame. Turn either on where that outcome means work that was owed did not happen.
Whatever those say about the FIXED per-run charge, measured spend always
books. A run cancelled after burning 40k tokens reports those tokens in its
ai block and the ledger charges them: money demonstrably spent is not a
policy question.
Shared costs allocated by_volume follow the same basis, so an automation
that skips most of the time stops absorbing shared cost as though it ran.
Send a failure_reason to say why: nothing to process or
outside business hours on a skip, ci/timeout or ci/superseded on a
cancellation. Each status keeps its own Pareto on the automation page, and
all are filterable on the run list, which is how "this job skips 90% of the
time" turns into "so run it half as often" and a wall of ci/timeout turns
into a timeout that wants raising.
Alerts. An automation that quietly does nothing is invisible to a failure
chart, so LumaTrack watches skips directly. A skip spike (three quarters
or more of the last 24 hours' runs skipped, on at least 10 runs) and a skip
streak (10 consecutive skips with nothing in between) each raise an alert
and fire the run.skipped_threshold webhook. Both thresholds
are configurable per organization in Settings.
A skip spike clears itself once the 24-hour window rolls forward, so it can fire again the next day. A streak does not clear until a non-skipped run lands, so it alerts once and then waits: acknowledge the alert to arm it again, rather than being told the same unchanged fact every night.