Skip to content

Webhooks

Webhooks turn the events LumaTrack already tracks into a push instead of a poll. Register a URL, and we POST it a signed JSON body the moment one of these happens:

Event Fires when
run.held A run is held because the org hit its monthly event cap (stored, never dropped, excluded from value math until you upgrade or the month resets).
period.closed A month becomes eligible and is frozen (soft close).
alert.fired A failure-spike or volume-drop alert fires for an automation.
run.skipped_threshold An automation crosses a skipped-run threshold: a skip spike (share of recent runs that did no work) or a skip streak (consecutive skips). Separate from alert.fired so failure-spike subscribers are not handed a new alert class. Payload carries kind, message, and either skipped/total/rate/threshold or streak/threshold.
api_key.created An API key is minted (in the app or over the API). The payload carries the key's id, name, prefix, and scope; never the key itself.
report_link.created A shareable report link is created. The payload carries the link's id, kind, and the share URL itself, ready to route to Slack, a ticket, or an email.
shared_cost.created A shared cost is added.
webhook.created A webhook endpoint is registered. The payload never carries the signing secret.
event.recorded An incident is recorded (see Incidents). The payload carries the event id, type slug, occurrence time, and capture source.
event.downtime_recorded An incident's measured downtime was recorded or restated; the payload carries the minutes.
event.resolved Deprecated. Fires only from the legacy resolve route (see Incidents); the payload carries the measured downtime. Subscribe to event.downtime_recorded instead.
initiative.implemented An initiative is marked implemented; the payload carries the frozen measured baseline its realization will be judged against.
initiative.transitioned An initiative is turned into an automation (see Incidents). The payload carries the mode (link or convert), the initiative_id, and the new automation_id and name.

The four creation events also carry created_by: the email of the person who performed the action, or null when the action came through the API (a key acted, not a person). Note that report_link.created deliveries contain the share URL, which is itself the access capability: point those events only at endpoints you would trust with the report.

Registering an endpoint

In the app: Settings, Webhooks. Over the API, with a full-scope key:

curl -s -X POST "$LUMATRACK_URL/api/v1/webhooks" \
  -H "Authorization: Bearer $LUMATRACK_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://your-app.example/hooks/lumatrack",
       "events": ["run.held", "alert.fired"]}'

Pass an empty events list (or omit it) to receive every event type. The response returns the endpoint, including its signing secret. Store the secret now: it is how you verify deliveries, and this is the only time it is returned. GET /api/v1/webhooks and the settings page never show it again, because a credential that is re-served on every read is a credential anyone who borrows a session or a key walks away with.

If you lose it, or you think someone else has it, rotate:

curl -X POST https://app.lumatrack.io/api/v1/webhooks/{id}/rotate \
  -H "Authorization: Bearer $LUMATRACK_API_KEY"

That returns the new secret once and keeps the endpoint's id, URL, test URL and event subscription, so nothing keyed on the endpoint id breaks. Deliveries are signed with the new secret from the next send, so update your receiver at the same time. The same control is on the settings page.

Ingest-only keys cannot manage webhooks (they get 403), the same deny-by-default scope rule as the rest of the API.

List your endpoints with GET /api/v1/webhooks, and retire one with DELETE /api/v1/webhooks/{id} when an endpoint is stale or compromised.

The delivery

Each delivery is a POST with this body:

{
  "event": "run.held",
  "data": {
    "automation": "nightly-invoice-sync",
    "external_id": "job-1234",
    "plan": "free"
  }
}

Headers:

  • X-LumaTrack-Event the event name.
  • X-LumaTrack-Signature HMAC-SHA256 of the raw request body, keyed by the endpoint's signing secret, hex-encoded.

Testing an endpoint

In Settings, Webhooks, every registered endpoint has a Send test control: pick an event type and click Send test. LumaTrack POSTs a signed delivery of that event to your URL immediately, through the same signing and delivery code as real events, and shows you the response your endpoint returned. Use it to build and prove your ingest pipeline, signature verification included, before any live event fires.

Test deliveries use the real event name, so your event routing (for example an n8n trigger's event filter) behaves exactly as it will in production. The payload carries the real event's keys with sample values, plus two extras that mark it as a test: "test": true and the endpoint's id. If your pipeline takes actions with side effects, check the test flag; it is the one reliable discriminator. The generic webhook.test option stays available for a plain reachability-and-signature check.

Test sends ignore the endpoint's event subscription filter: you chose the event explicitly, so it is sent even if the endpoint would not subscribe to it live.

An endpoint can also carry an optional test URL, signed with the same secret: set it to your consumer's test receiver (n8n's webhook-test/... URL, a staging listener) and Send-test deliveries go there instead of the production URL. Real events never go to the test URL. This keeps ONE signing secret across test and production, so verifying your pipeline never means swapping secrets in the consumer.

JSON or XML payloads

An endpoint delivers JSON by default. Set its content type to xml (in Settings, Webhooks, or content_type: "xml" on POST /api/v1/webhooks) and deliveries arrive as XML instead, with Content-Type: application/xml:

<webhook><event>run.held</event><data><automation>nightly-invoice-sync</automation></data></webhook>

The signature works identically either way: it is the HMAC-SHA256 of the exact bytes sent, so verify against the raw body regardless of format.

Verifying the signature

Always verify the signature before trusting a delivery. Compute the HMAC of the raw body with your stored secret and compare it in constant time:

import hashlib
import hmac

def verify(secret: str, raw_body: bytes, header_sig: str) -> bool:
    expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, header_sig)
const crypto = require("crypto");

function verify(secret, rawBody, headerSig) {
  const expected = crypto
    .createHmac("sha256", secret)
    .update(rawBody)
    .digest("hex");
  return crypto.timingSafeEqual(
    Buffer.from(expected),
    Buffer.from(headerSig),
  );
}

Compute the HMAC over the exact bytes you received, before any JSON re-serialization, or the signature will not match.

Delivery semantics

  • Respond 2xx to acknowledge. Any non-2xx response, a timeout (10s), or a connection error counts as a failure.
  • Latency. Deliveries are pushed by an always-on worker and usually arrive within a couple of seconds of the event.
  • Retries. A failed delivery is retried with a growing backoff (about one more minute per attempt), up to five attempts, then it stops. Deliveries are not strictly ordered, and an event may arrive more than once, so make your handler idempotent (the external_id on run.held, or the period on period.closed, are natural dedup keys).
  • Decoupled from ingest. Delivery happens out of band, so a slow or dead endpoint never slows down the run that triggered it.