Documentation

API & integration docs

Everything on this page is the shipped behavior of the API: how to open an incident with your service key, and how to receive and verify the signed result.

Ingest an incident

Each service you create in the dashboard exposes one ingest endpoint, addressed by the service’s slug:

Endpoint

POST /api/v1/services/<service-slug>/incidents

Authentication

Authenticate with the service API key as a bearer token. Keys have the form ocai_live_… and are generated when you create a service. The plaintext is shown once and only a hash is stored, so copy it at creation; you can rotate the key from the service’s settings at any time, which invalidates the old one.

Required headers

Authorization: Bearer ocai_live_…
Idempotency-Key: <your key, up to 128 characters>
Content-Type: application/json

Idempotency

The Idempotency-Key header is required on every request and is limited to 128 characters. Use a value that identifies the alert, such as the alert ID from your monitoring system. Sending the same key to the same service again returns the original incidentId and runId instead of opening a second case, so a retrying alerting pipeline cannot duplicate work.

Request body

The body is JSON. Unknown fields are rejected, the whole request is limited to 256 KiB, and the stack trace plus all log messages together are limited to 128 KiB. Values that look like secrets — API keys, tokens, webhook URLs, private keys — are redacted before the payload is stored.

FieldTypeDescription
externalId requiredstringYour identifier for the incident, such as the alert ID. 1–255 characters.
title requiredstringShort human-readable summary. 1–500 characters.
severity requiredstringOne of critical, high, medium, low, or unknown.
occurredAt requiredstringWhen the incident began, as an ISO 8601 timestamp with a timezone, for example 2026-08-20T14:02:00Z.
description stringFree-form detail about what you observed. Up to 32,000 characters.
environment stringFor example production or staging. Up to 128 characters.
stackTrace stringRaw stack trace text. Up to 128 KiB.
logs arrayUp to 2,000 log entries. Each entry has a required message plus optional timestamp (ISO 8601 with timezone) and level.
primaryCommitSha stringFull 40-character hex SHA of the commit to investigate. When omitted, Legwork resolves the current head of the primary repository’s default branch.
context objectFlat key-value map for extra metadata. Keys up to 128 characters; values are strings (up to 4,096 characters), numbers, booleans, or null.

Example

Open an incident

curl -X POST https://legwork.dev/api/v1/services/payments-api/incidents \
  -H "Authorization: Bearer $LEGWORK_API_KEY" \
  -H "Idempotency-Key: grafana-alert-8412" \
  -H "Content-Type: application/json" \
  -d '{
    "externalId": "grafana-alert-8412",
    "title": "5xx spike on /checkout",
    "severity": "high",
    "occurredAt": "2026-08-20T14:02:00Z",
    "environment": "production",
    "description": "Error rate stepped from 0.2% to 14% at 14:02 UTC.",
    "stackTrace": "panic: read /var/cache/layers/3f2a: file already closed\n\tinternal/archive/reader.go:210\n\tinternal/cache/layers.go:94",
    "logs": [
      {
        "timestamp": "2026-08-20T14:02:11Z",
        "level": "error",
        "message": "archive read aborted: file already closed"
      }
    ],
    "context": { "region": "eu-west-1", "deploy": "0fab160216e8" }
  }'

202 Accepted

{
  "incidentId": "6f1f6a2e-9c62-4d0a-8f3b-2b7e21d5c4a1",
  "runId": "b4d1c9f2-3e58-4a7c-9b21-d6f0a8e35c72",
  "status": "queued",
  "dashboardUrl": "https://legwork.dev/incidents/6f1f6a2e-9c62-4d0a-8f3b-2b7e21d5c4a1"
}

A 202 means the incident is stored and an analysis run is queued. Follow dashboardUrl to watch the investigation live.

Rate limits and response codes

Each service accepts up to 60 incident requests per minute. Monthly investigation and model-token quotas from your plan also apply. Errors are returned as application/problem+json (RFC 9457) with a title and detail.

StatusMeaning
202Accepted. The incident is queued for analysis and the response body contains the IDs above. Retries with the same Idempotency-Key also return 202 with the original IDs.
400The Idempotency-Key header is missing or too long, or the body is not valid JSON.
401The bearer key is missing, invalid, revoked, or the service is disabled.
413The request body exceeds 256 KiB.
422Validation failed. The errors field maps each invalid field to its validation messages.
429The per-service rate limit of 60 requests per minute was exceeded, or your organization’s monthly investigation or model-token quota is exhausted.

Receive results

If you configure a callback URL on a service, Legwork POSTs a signed JSON payload to it when a run reaches a terminal state. The callback signing secret is generated together with the service and shown once, next to the API key. The URL must be HTTPS on port 443, carry no credentials, and resolve to a public address — private and internal hosts are rejected.

Events and headers

One of three events is delivered per run: analysis.completed, analysis.inconclusive, or analysis.failed. Every request carries these headers:

HeaderValue
x-legwork-eventanalysis.completed, analysis.inconclusive, or analysis.failed.
x-legwork-deliveryUnique ID for this delivery. Use it to deduplicate retried deliveries.
x-legwork-timestampUnix timestamp in seconds at the moment of the sending attempt.
x-legwork-signaturev1= followed by a hex HMAC-SHA256 of the timestamp and the raw request body, joined with a period, keyed with your callback signing secret.
content-typeapplication/json.

Payload

The body carries the summary and a confidence score between 0 and 1 (both null when a run fails before producing a diagnosis). It deliberately omits logs, source excerpts, and patches; the full evidence trail stays behind dashboardUrl.

Callback body

{
  "schemaVersion": "1",
  "event": "analysis.completed",
  "incidentId": "6f1f6a2e-9c62-4d0a-8f3b-2b7e21d5c4a1",
  "runId": "b4d1c9f2-3e58-4a7c-9b21-d6f0a8e35c72",
  "status": "completed",
  "summary": "Concurrent cache eviction interrupts active archive reads. The evict path calls os.RemoveAll without coordinating with active readers (internal/cache/layers.go:94-107).",
  "confidence": 0.92,
  "dashboardUrl": "https://legwork.dev/incidents/6f1f6a2e-9c62-4d0a-8f3b-2b7e21d5c4a1",
  "completedAt": "2026-08-20T14:04:23Z"
}

Verifying the signature

Compute an HMAC-SHA256 over the timestamp header and the raw request body joined with a period, using your callback signing secret. Prefix the hex digest with v1= and compare it to x-legwork-signature with a constant-time comparison:

Verify (Node.js)

import { createHmac, timingSafeEqual } from 'node:crypto';

// rawBody must be the exact request body bytes as received,
// not a re-serialized copy of the parsed JSON.
function verifyLegworkSignature(headers, rawBody, secret) {
  const timestamp = headers['x-legwork-timestamp'];
  const signature = headers['x-legwork-signature'] ?? '';
  const expected = `v1=${createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex')}`;
  const a = Buffer.from(expected);
  const b = Buffer.from(signature);
  return a.length === b.length && timingSafeEqual(a, b);
}

Delivery and retries

Respond with any 2xx status within 10 seconds. Anything else counts as a failed attempt and is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, and 24 hours before the delivery is marked permanently failed. Retries reuse the same x-legwork-delivery ID, so you can deduplicate on it.

Notification channels

If you would rather read findings where your team already is, add Slack, Discord, or email channels at /notifications in the dashboard — no code required. Slack and Discord take an incoming webhook URL and work immediately; email channels deliver the finding by mail.

  • Each channel routes to every service by default; narrow it to specific services when you want separate destinations per team.
  • Send test posts a real message through the same delivery path a diagnosis takes, so you can confirm the wiring before the next incident.
  • Messages carry the summary, the root-cause statement with its confidence, and cited file paths with line ranges — never source excerpts or patches. The full trail stays behind the dashboard link.