Skip to content

First workflow

Wire one workflow in 20 minutes.

Start with one customer-visible workflow. Define the promise, send one healthy signal, run one deliberate failure drill, and confirm Luota opens an incident with enough context for the next operator action.

Proof path

Healthy signal, deliberate failure, operator action.

  1. Create a workflow monitor in the workspace.
  2. Copy the ingest token from the monitor's integration tab.
  3. Send a heartbeat or run event from your job.
  4. Confirm Luota received it on the dashboard.
  5. Trigger a deliberate failure — skip a heartbeat, fail a run, let the data go stale.
  6. Confirm Luota opens one incident with the failure context.
  7. Add the owner, runbook, and alert channel.

Buyer record

The docs are part of the product surface, not just installation copy.

Luota is pre-customer, so the commercial site shows product behavior, public controls, and buying mechanics directly. No invented testimonials, logo walls, or compliance claims.

Product record

Actual surfaces, not borrowed credibility

Inspect the dashboard, monitor detail, incident record, demo workspace, and deliberate failure drill before trusting the product.

Public controls

Controls and limits are public

Review the current security posture, privacy terms, DPA, subprocessor list, disclosure path, and live service status.

Billing state

Buying rules match product limits

Confirm workflow limits, retention, Stripe responsibility, cancellation behavior, and what changes after checkout.

Assembly path

The docs, onboarding, monitor page, and incident page describe one object.

Luota should never make you translate between a marketing claim, an integration guide, and a dashboard table. The same workflow moves from draft to healthy signal to incident to retained history.

Promise

Name the workflow outcome

Choose the customer-visible promise: billing access changed, report delivered, generated output appeared, or data stayed fresh.

Onboarding

Signal

Attach one live event

Use a heartbeat or run lifecycle event from the job that already runs. Keep the token and monitor id paired.

Integration

Healthy proof

Confirm one accepted event

A 202 response means the event landed. The monitor detail page shows the latest signal, payload keys, host, and deploy SHA when attached.

Monitor

Bad-day proof

Run a controlled miss or failure

Skip a heartbeat, fail a run, or let freshness expire. Trust starts only after the incident path is visible.

Incident

Handoff

Leave an operator path

Add owner, runbook, and alert route so the packet is useful when someone else opens it later.

Audit

Mode

Heartbeat

Best when one success signal is enough — the job runs, the heartbeat lands, the workflow is healthy.

Mode

Run lifecycle

Best when the job should emit start and finish events and failure context matters: duration, exit reason, output.

Mode

Freshness

Best when the output should keep changing, even without a visible job boundary — reports, caches, derived data.

Integration examples

Pick the smallest signal that proves the job.

Heartbeats prove a job finished. Run lifecycle events prove start, success, failure, duration, and failure context.

Heartbeat example

Vercel Cron

Use a heartbeat monitor when the job only needs to say “I finished at all.”

await fetch(`${process.env.LUOTA_API_BASE_URL}/v1/monitors/${process.env.LUOTA_MONITOR_ID}/ping`, {
  method: "POST",
  headers: {
    "content-type": "application/json",
    "x-luota-key": process.env.LUOTA_INGEST_TOKEN!
  },
  body: JSON.stringify({
    occurredAt: new Date().toISOString(),
    payload: { source: "vercel-cron" }
  })
});

Run lifecycle example

GitHub Actions

Use a run lifecycle monitor when failure, lateness, stuck runs, or duration should become part of the incident.

- name: Tell Luota the job started
  run: |
    curl -X POST "$LUOTA_API_BASE_URL/v1/runs/start" \
      -H "x-luota-key: $LUOTA_INGEST_TOKEN" \
      -H "content-type: application/json" \
      -d "{\"monitorId\":\"$LUOTA_MONITOR_ID\",\"externalRunId\":\"${{ github.run_id }}\",\"deploySha\":\"${{ github.sha }}\",\"host\":\"github-actions\"}"

Workflow templates

Start with the customer-visible outcome.

These are not new product silos. They are the first three patterns to wire when “the job ran” is not enough: billing access changed, report delivered, or generated output appeared.

Template

Stripe entitlement proof

A billing event is complete only after subscription state, entitlement state, and customer notification all match the expected outcome.

  • Start the run when the Stripe event is accepted.
  • Attach the Stripe event id, customer id, subscription id, and deploy SHA.
  • Close success only after access changed and the customer-facing notification step finished.
// startRun is your small fetch wrapper around /v1/runs/start.
const run = await startRun("billing-entitlement-sync", {
  externalRunId: stripeEvent.id,
  payload: {
    source: "stripe",
    eventType: stripeEvent.type,
    customerId,
    subscriptionId
  }
});

await syncSubscriptionState(stripeEvent);
await assertEntitlementState(customerId);
await sendBillingStateEmail(customerId);

await run.success({
  summary: "Subscription, entitlement, and notification completed"
});

Template

Scheduled report delivery proof

A report is complete only after generation and the provider delivery receipt are both recorded.

  • Start the run before query/render work begins.
  • Attach report id, account id, schedule window, and provider message id.
  • Fail the run if the provider rejects, delays, or never returns a usable delivery response.
// startRun is your small fetch wrapper around /v1/runs/start.
const run = await startRun("weekly-customer-report", {
  externalRunId: reportRunId,
  payload: { reportId, accountId, periodStart, periodEnd }
});

const report = await renderReport(accountId);
const delivery = await sendReportEmail(report);

if (!delivery.accepted) {
  await run.fail({ summary: "Email provider did not accept report delivery", payload: delivery });
  return;
}

await run.success({ summary: "Report rendered and accepted by email provider", payload: delivery });

Template

Generated output proof

A model batch is complete only after generated output validates, persists, and becomes visible to the user.

  • Start the run with the batch id before model calls begin.
  • Attach model, prompt version, validation result, and output record id.
  • Close success only after the saved result is readable from the user-visible surface.
// startRun is your small fetch wrapper around /v1/runs/start.
const run = await startRun("ai-output-batch", {
  externalRunId: batch.id,
  payload: { model, promptVersion, inputCount: batch.items.length }
});

const outputs = await runModelBatch(batch);
const validation = await validateOutputs(outputs);
const saved = await persistValidatedOutputs(validation);
await assertUserVisibleOutput(saved.outputId);

await run.success({
  summary: "Generated output validated, saved, and visible",
  payload: { outputId: saved.outputId, validCount: validation.validCount }
});

Failure drill

Prove it catches a deliberate failure.

A workflow monitor has not earned trust until you have watched it fail on purpose. Run one drill before wiring twenty background jobs.

Missed heartbeat drill

Create a heartbeat workflow with a short grace window, send one healthy ping, then skip the next expected ping. Confirm Luota opens a missed-work incident instead of silently staying green.

Failed run drill

Create a run lifecycle workflow, send /runs/start, then deliberately close it with /fail and a realistic summary. Confirm the incident contains the summary, payload, owner, and run timeline.

Stale output drill

Create a freshness workflow for a report/table/output timestamp, send one current signal, then let the freshness window expire. Confirm Luota reports stale output, not infrastructure downtime.

Monitor detail

Docs and product meet on the monitor page.

Signed snippet, recent runs, recent incidents, alert channels, and the monitor definition all live on one page — so docs become an operator workflow, not a separate site.

  • Schedule. Cron or interval, plus the timezone and the grace window before a run is late.
  • Recent runs. Last 25 events with state, duration, and payload preview.
  • Open incidents. Linked from the monitor; the timeline picks up where the run feed stops.
  • Alert channels. Slack, email, or webhook routing — each with last-delivery state.
  • Owner & runbook. Plain-text owner label and a runbook URL that opens with the alert.
  • Signed snippet. Copy-paste curl / Vercel / GitHub Actions example wired with the monitor's ingest token.

API reference

Four ingest endpoints. No SDK required.

Every integration uses one of these endpoints with the x-luota-key header. Monitor tokens authorise a single monitor; workspace tokens are accepted only by the workspace-scoped ping route.

All endpoints accept JSON bodies, return JSON, and respond with HTTP 202 Accepted on success.

POST/v1/monitors/:monitorId/pingHeartbeat ping

Used by heartbeat and freshness monitors. Each ping resets the lateness window; if no ping arrives before the grace expires, an incident opens.

Body fields

occurredAtstring (ISO 8601)Optional
When the work finished. Defaults to server-receive time.
payloadobjectOptional
Free-form JSON for context (build SHA, source, etc.). Stored on the heartbeat and surfaced in the timeline.
response202 accepted
{
  "ok": true,
  "monitor": {
    "id": "22222222-2222-4222-8222-222222222222",
    "status": "healthy"
  },
  "heartbeat": {
    "id": "33333333-3333-4333-8333-333333333333",
    "occurredAt": "2026-04-29T01:33:11.398Z"
  }
}
bad dayError responses
  • 401Missing or invalid x-luota-key header for this monitor.
    { "error": "Invalid ingest key" }
    X-Request-Id: req_01HX_sample
  • 404Monitor id does not exist or was archived.
    { "error": "Monitor not found" }
  • 413Raw JSON body exceeded 64 KiB before parsing.
    { "code": "request_failed", "error": "Request failed" }
  • 429Token, workspace, or IP rate limit exceeded. Respect Retry-After.
    { "error": "Rate limit exceeded", "policy": "ingest.monitor_token.minute", "retryAfterSeconds": 21, "limit": 60 }
    Retry-After: 21
    X-Request-Id: req_01HX_sample
POST/v1/runs/startRun start

Opens a run record on a run-lifecycle monitor. Use the returned run.id as the handle for the matching success/fail call below.

Body fields

monitorIduuidRequired
Monitor that owns this run.
externalRunIdstringRequired
Your stable id for the job (CI run id, queue job id, etc.). Used for idempotency: re-sending the same externalRunId is safe.
startedAtstring (ISO 8601)Optional
When the work began. Defaults to server-receive time.
payloadobjectOptional
Free-form JSON saved on the run.
deployShastringOptional
Source commit shipped with this run; surfaced inside the incident.
hoststringOptional
Hostname / runner / region. Helps correlate runs to a fleet.
environmentstringOptional
production / staging / preview. Filterable in the dashboard.
tagsobject<string,string>Optional
Up to 16 key/value tags for ad-hoc grouping. Keys ≤ 40 chars, values ≤ 120 chars.
response202 accepted
{
  "monitor": {
    "id": "22222222-2222-4222-8222-222222222222"
  },
  "run": {
    "id": "44444444-4444-4444-8444-444444444444",
    "externalRunId": "nightly-backup-2026-04-29",
    "startedAt": "2026-04-29T01:33:11.398Z"
  }
}
bad dayError responses
  • 401Missing or invalid x-luota-key header for this monitor.
    { "error": "Invalid ingest key" }
    X-Request-Id: req_01HX_sample
  • 404Monitor id does not exist.
    { "error": "Monitor not found" }
  • 429Duplicate retries are safe, tight loops are not. Back off using Retry-After.
    { "error": "Rate limit exceeded", "retryAfterSeconds": 21 }
    Retry-After: 21
    X-Request-Id: req_01HX_sample
POST/v1/runs/:runId/successRun success

Closes a run as successful. Any open incident on the same monitor is auto-resolved by the next healthy event.

Body fields

finishedAtstring (ISO 8601)Optional
When the run completed. Defaults to server-receive time.
summarystring (≤ 240 chars)Optional
Short human description shown on the run card.
outputstringOptional
Captured stdout / log tail. Output must fit in 32 KiB after UTF-8 encoding. Over the cap returns HTTP 400 with field: "output" — pass a URL to object storage instead of inlining.
payloadobjectOptional
Free-form JSON context.
deployShastringOptional
Override the deploy SHA stored on the run.
host / environment / tagsstring / string / objectOptional
Same shape as run start; overrides the values set on /runs/start.
response202 accepted
{
  "run": {
    "id": "44444444-4444-4444-8444-444444444444",
    "status": "success",
    "finishedAt": "2026-04-29T01:34:02.781Z",
    "durationSeconds": 51
  }
}
bad dayError responses
  • 401Token does not authorise this run, or the run id does not exist.
    { "error": "Invalid ingest key or run not found" }
  • 409Run is already failed and cannot be flipped to success.
    { "error": "Run is already failed and cannot be marked successful." }
POST/v1/runs/:runId/failRun failure

Closes a run as failed. Opens or updates an incident on the monitor with the failure context.

Body fields

summarystring (1-240 chars)Required
Required. Operator-facing description of the failure ("3 of 14 syncs returned 500").
finishedAtstring (ISO 8601)Optional
When the run gave up.
exitCodeintegerOptional
Exit / status code, if applicable.
outputstringOptional
Captured stderr / log tail.
payloadobjectOptional
Free-form JSON context.
deployShastringOptional
Override the deploy SHA on the run.
host / environment / tagsstring / string / objectOptional
Same shape as run start.
response202 accepted
{
  "run": {
    "id": "44444444-4444-4444-8444-444444444444",
    "status": "failed",
    "finishedAt": "2026-04-29T01:34:02.781Z"
  },
  "monitor": {
    "status": "incident"
  }
}
bad dayError responses
  • 401Token does not authorise this run, or the run id does not exist.
    { "error": "Invalid ingest key or run not found" }
  • 409Run is already successful and cannot be flipped to failed.
    { "error": "Run is already successful and cannot be marked failed." }

Authentication

Per-monitor ingest tokens.

Each monitor has its own short token derived from a server-side signing secret plus the monitor, workspace, and current token salt. The token only authorises calls to that one monitor. Lose a token, you lose access to exactly one monitor.

Header

x-luota-key: <monitor token>

Sent on every ingest request. Any other authorisation header is ignored. Tokens are not JWTs — there is no client-side expiry; the server treats them as opaque.

Scope

One token = one monitor. The token derivation also encodes the workspace id, so a monitor cannot be moved between workspaces by lifting its token.

Rotation

Rotate from the monitor's Integration tab if you suspect a token has leaked. Rotation changes the monitor salt and immediately rejects the previous token on heartbeat and run lifecycle endpoints.

Environment

Three variables.

Set these once in your CI / hosting provider and the snippets above run unmodified.

LUOTA_API_BASE_URL
Always https://luota.dev. Override only for staging / self-host.
LUOTA_INGEST_TOKEN
Per-monitor ingest token. Generated on monitor creation; rotate from the monitor's Integration tab if it leaks.
LUOTA_MONITOR_ID
UUID shown on the monitor's Integration tab. Required for /v1/monitors/:monitorId/ping calls.

Failure modes

What to expect when something breaks.

Retries, idempotency, payload limits, and clock-skew behaviour — the boring details a real integration needs.

Network failure / 5xx from Luota
Retry with exponential backoff for up to ~5 attempts. The runs API is idempotent on externalRunId: re-sending the same start payload returns the existing run.id, so retries cannot create duplicate runs.
Idempotency
Run starts: re-sending /v1/runs/start with the same monitorId + externalRunId returns the existing run.id without creating a new run. Run closes: repeating the same success or fail call returns the already-closed run; trying to flip a failed run to success, or a successful run to failed, returns HTTP 409.
Request id visibility
Every API response includes X-Request-Id. Capture that value with the HTTP status and response body in job logs; it lets support correlate invalid tokens, payload-too-large responses, rate limits, and retry loops without exposing your ingest token or raw payload.
Rate limits
Each monitor token gets 60 requests/minute burst and 1,000 requests/hour sustained. Workspace and IP have separate ceilings for abuse protection. Over the limit returns HTTP 429 with a Retry-After header and a JSON body { error: "Rate limit exceeded", policy, retryAfterSeconds, limit }. Heartbeats firing faster than ~once/minute are almost always wrong — Luota is built for the work that runs on a schedule, not a tight loop.
Body and payload size
Request body must be ≤ 64 KiB before parsing. payload must be ≤ 32 KiB after JSON encoding and output must be ≤ 32 KiB after UTF-8 encoding. Payload/output schema violations return HTTP 400 with { error: "Invalid request", message, field }. Oversized raw bodies return HTTP 413. Large outputs belong in object storage; pass a URL in payload instead of inlining.
Tag limits
Up to 16 key/value tags per event. Tag keys ≤ 40 chars; tag values ≤ 120 chars. Exceeding any of these returns HTTP 400 with the offending field named so you can fix the client without guessing.
Object-only payload
payload must be a JSON object — Record<string, unknown>. Strings, arrays, numbers, and nulls are rejected with HTTP 400 { error: "Invalid request", message, field: "payload" }. No silent rejects: if the API returns 202, the event landed.
Clock skew
occurredAt / startedAt / finishedAt accept any ISO 8601 timestamp. Skew of a few seconds is fine; skew > 5 minutes will look like a stuck or late run on the timeline. Default to omitting the field and let the server timestamp it.
Alert delivery failed or retrying
Ingest success and human-visible alert delivery are separate facts. If a failing run opens an incident but the email or webhook provider rejects the alert, incident detail shows failed_retrying or failed_terminal. Repair the destination and send a test alert before treating that route as trusted.

Troubleshooting

Common failures, with the actual fix.

Symptom → cause → next step. Designed to short-circuit the diagnostic chain that operators usually have to assemble from logs.

401 "Invalid ingest key"
The token does not match this monitor. Check that LUOTA_INGEST_TOKEN belongs to the monitor whose id is in the URL. Tokens are scoped per-monitor; copy-pasting from a different monitor's Integration tab will fail this way.
404 "Monitor not found"
Monitor was archived or deleted. Recreate the monitor and update the snippet with the new id + token. The old token cannot reach the new monitor.
Run shows as stuck / no completion
The /runs/start call landed but the matching /runs/:runId/success or /fail call did not. Common causes: job process killed before reaching the close call; HTTP error on close swallowed in client code. Always wrap close calls so failure paths still emit a /fail.
Run shows as late
Heartbeat or run start arrived after the grace window. Check the monitor's schedule + grace seconds; if the job genuinely runs that late, widen the grace, do not silence the monitor.
Freshness drift
Output is technically updating but slower than the configured Expected freshness seconds. Either the upstream pipeline slowed down (real signal — investigate) or the threshold was set too tight. The monitor detail page shows the last few ping intervals so you can pick a grounded threshold.
Missing payload in the timeline
Payload was sent but the field is empty in the dashboard. Cause is almost always payload-as-string instead of payload-as-object. The API now rejects this with HTTP 400 { error: "Invalid request", field: "payload" } — if you see an empty payload AND the call returned 202, double-check the client is reading the response body and surfacing 400s instead of swallowing them.

More platforms

Wire it from your scheduler.

Same env-var contract as the Vercel Cron and GitHub Actions examples in the hero. Copy a block, set the three env vars, ship the job.

GitLab CI

Run lifecycle, emitting start/success around the actual job step.
nightly_sync:
  script:
    - |
      RUN=$(curl -fsS -X POST "$LUOTA_API_BASE_URL/v1/runs/start" \
        -H "x-luota-key: $LUOTA_INGEST_TOKEN" \
        -H "content-type: application/json" \
        -d "{\"monitorId\":\"$LUOTA_MONITOR_ID\",\"externalRunId\":\"$CI_JOB_ID\",\"deploySha\":\"$CI_COMMIT_SHA\"}")
      RUN_ID=$(echo "$RUN" | jq -r .run.id)
      ./run-the-actual-job.sh && \
        curl -fsS -X POST "$LUOTA_API_BASE_URL/v1/runs/$RUN_ID/success" \
          -H "x-luota-key: $LUOTA_INGEST_TOKEN" || \
        curl -fsS -X POST "$LUOTA_API_BASE_URL/v1/runs/$RUN_ID/fail" \
          -H "x-luota-key: $LUOTA_INGEST_TOKEN" \
          -H "content-type: application/json" \
          -d '{"summary":"job failed"}'

CircleCI

Heartbeat at the end of the job — simplest possible integration.
jobs:
  hourly_report:
    docker: [{ image: cimg/base:current }]
    steps:
      - run: ./generate-report.sh
      - run: |
          curl -fsS -X POST "$LUOTA_API_BASE_URL/v1/monitors/$LUOTA_MONITOR_ID/ping" \
            -H "x-luota-key: $LUOTA_INGEST_TOKEN" \
            -H "content-type: application/json" \
            -d "{\"payload\":{\"build\":\"$CIRCLE_BUILD_NUM\",\"sha\":\"$CIRCLE_SHA1\"}}"

Render Cron Job

One curl line in the cron's start command. No SDK install.
# Render Cron Job → Start Command:
sh -c 'node ./generate-report.js && curl -fsS -X POST \
  "$LUOTA_API_BASE_URL/v1/monitors/$LUOTA_MONITOR_ID/ping" \
  -H "x-luota-key: $LUOTA_INGEST_TOKEN" \
  -H "content-type: application/json" \
  -d "{\"payload\":{\"source\":\"render-cron\"}}"'

Fly.io scheduled machine

Lightweight wrapper around the actual command.
# fly.toml (scheduled machine entry)
[processes]
  refresh_cache = "sh -c 'node refresh.js && curl -fsS -X POST \\
    \"$LUOTA_API_BASE_URL/v1/monitors/$LUOTA_MONITOR_ID/ping\" \\
    -H \"x-luota-key: $LUOTA_INGEST_TOKEN\"'"

[[scheduled]]
  process = "refresh_cache"
  schedule = "0 * * * *"

Plain crontab

Heartbeat after the actual command. The trailing curl runs only on success.
# /etc/cron.d/nightly-sync
0 2 * * * deploy /opt/jobs/nightly-sync.sh && \
  curl -fsS -X POST \
    "$LUOTA_API_BASE_URL/v1/monitors/$LUOTA_MONITOR_ID/ping" \
    -H "x-luota-key: $LUOTA_INGEST_TOKEN" \
    -H "content-type: application/json" \
    -d '{"payload":{"source":"systemd-host"}}'

systemd timer

ExecStartPost only runs if the main ExecStart succeeded.
# /etc/systemd/system/nightly-sync.service
[Service]
EnvironmentFile=/etc/luota.env
ExecStart=/opt/jobs/nightly-sync.sh
ExecStartPost=/usr/bin/curl -fsS -X POST \
  $LUOTA_API_BASE_URL/v1/monitors/$LUOTA_MONITOR_ID/ping \
  -H "x-luota-key: $LUOTA_INGEST_TOKEN"

# /etc/systemd/system/nightly-sync.timer
[Timer]
OnCalendar=*-*-* 02:00:00