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. |
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; it is how you verify deliveries.
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.
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. - Retries. A failed delivery is retried on subsequent flushes, 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.