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.
| Field | Type | Description |
|---|---|---|
externalId required | string | Your identifier for the incident, such as the alert ID. 1–255 characters. |
title required | string | Short human-readable summary. 1–500 characters. |
severity required | string | One of critical, high, medium, low, or unknown. |
occurredAt required | string | When the incident began, as an ISO 8601 timestamp with a timezone, for example 2026-08-20T14:02:00Z. |
description | string | Free-form detail about what you observed. Up to 32,000 characters. |
environment | string | For example production or staging. Up to 128 characters. |
stackTrace | string | Raw stack trace text. Up to 128 KiB. |
logs | array | Up to 2,000 log entries. Each entry has a required message plus optional timestamp (ISO 8601 with timezone) and level. |
primaryCommitSha | string | Full 40-character hex SHA of the commit to investigate. When omitted, Legwork resolves the current head of the primary repository’s default branch. |
context | object | Flat 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.
| Status | Meaning |
|---|---|
202 | Accepted. 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. |
400 | The Idempotency-Key header is missing or too long, or the body is not valid JSON. |
401 | The bearer key is missing, invalid, revoked, or the service is disabled. |
413 | The request body exceeds 256 KiB. |
422 | Validation failed. The errors field maps each invalid field to its validation messages. |
429 | The 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:
| Header | Value |
|---|---|
x-legwork-event | analysis.completed, analysis.inconclusive, or analysis.failed. |
x-legwork-delivery | Unique ID for this delivery. Use it to deduplicate retried deliveries. |
x-legwork-timestamp | Unix timestamp in seconds at the moment of the sending attempt. |
x-legwork-signature | v1= 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-type | application/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.