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.

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-Event the event name.
  • X-LumaTrack-Signature HMAC-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 2xx to 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_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.