Webhooks
A webhook subscription tells Lumis to POST an event payload to your own URL
whenever something happens in your project — instead of you polling
Tickets or Decisions for changes.
There’s no update endpoint yet — to change a subscription’s URL, secret, or event types, delete it and create a new one.
Event types
ticket.createdticket.updateddecision.resolvedaudit.event
/api/v1/webhooks/subscriptionsList your project’s webhook subscriptions.
curl https://lumis.work/api/v1/webhooks/subscriptions \
-H "Authorization: Bearer $LUMIS_MCP_TOKEN"const res = await fetch('https://lumis.work/api/v1/webhooks/subscriptions', {
headers: { authorization: `Bearer ${process.env.LUMIS_MCP_TOKEN}` },
});
const { subscriptions } = await res.json();res = requests.get(
"https://lumis.work/api/v1/webhooks/subscriptions",
headers={"authorization": f"Bearer {os.environ['LUMIS_MCP_TOKEN']}"},
)
subscriptions = res.json()["subscriptions"]{
"version": "v1",
"subscriptions": [
{
"id": "w4e7...",
"tenant_id": "acme-co",
"url": "https://example.com/hooks/lumis",
"event_types": ["ticket.created", "ticket.updated"],
"active": true,
"created_at": "2026-07-10T09:00:00.000Z",
"updated_at": "2026-07-10T09:00:00.000Z"
}
]
}The secret field is never included in list/read responses.
/api/v1/webhooks/subscriptionsCreate a subscription.
| Name | Type | Required | Description |
|---|---|---|---|
url | string | Yes | Your endpoint to receive POSTed events. |
secret | string | Yes | Shared secret used to sign every delivery. |
event_types | string[] | Yes | One or more of the event types above. |
curl -X POST https://lumis.work/api/v1/webhooks/subscriptions \
-H "Authorization: Bearer $LUMIS_MCP_TOKEN" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/hooks/lumis", "secret": "whsec_...", "event_types": ["ticket.created", "ticket.updated"]}'const res = await fetch('https://lumis.work/api/v1/webhooks/subscriptions', {
method: 'POST',
headers: {
authorization: `Bearer ${process.env.LUMIS_MCP_TOKEN}`,
'content-type': 'application/json',
},
body: JSON.stringify({
url: 'https://example.com/hooks/lumis',
secret: process.env.WEBHOOK_SECRET,
event_types: ['ticket.created', 'ticket.updated'],
}),
});
const { subscription } = await res.json();res = requests.post(
"https://lumis.work/api/v1/webhooks/subscriptions",
headers={"authorization": f"Bearer {os.environ['LUMIS_MCP_TOKEN']}"},
json={
"url": "https://example.com/hooks/lumis",
"secret": os.environ["WEBHOOK_SECRET"],
"event_types": ["ticket.created", "ticket.updated"],
},
)
subscription = res.json()["subscription"]An unrecognized event type returns 400 with the valid list. Returns 201
on success.
/api/v1/webhooks/subscriptions/:idDelete a subscription. Returns { "version": "v1", "ok": true }, or 404 if
it doesn’t exist.
Verifying a delivery
Every delivery is a POST with a JSON body and two headers:
| Name | Type | Description |
|---|---|---|
X-Lumis-Event | string | The event type, e.g. ticket.created. |
X-Lumis-Signature | string | sha256=<hex hmac> — HMAC-SHA256 of the raw JSON body, keyed with your subscription's secret. |
{
"event": "ticket.created",
"id": "8b6b5b6e-...",
"ts": "2026-07-16T18:04:22.000Z",
"tenant_id": "acme-co",
"data": { "id": "b3f1...", "project_key": "acme-co", "venture_key": "acme-co" }
}Recompute the HMAC over the exact raw request body with your secret and
compare it to X-Lumis-Signature before trusting a delivery:
import { createHmac, timingSafeEqual } from 'node:crypto';
function isValidDelivery(rawBody: string, signatureHeader: string, secret: string): boolean {
const expected = `sha256=${createHmac('sha256', secret).update(rawBody).digest('hex')}`;
const a = Buffer.from(expected);
const b = Buffer.from(signatureHeader);
return a.length === b.length && timingSafeEqual(a, b);
}A delivery that fails (non-2xx response, or your endpoint unreachable) is retried with exponential backoff — up to 6 total attempts before it’s dead-lettered — rather than dropped after the first try.