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-Eventthe event name.X-LumaTrack-SignatureHMAC-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
2xxto 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_idonrun.held, or theperiodonperiod.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.