Webhooks
Get notified when a customer or contract is created. Signed HTTPS POSTs with retries and a delivery log.
Lucius sends a signed POST to your endpoint when one of these happens:
| Event | When it fires |
|---|---|
customer.created | A customer is created — dashboard, POST /v1/billing/customers, or auto-created from an invoice or contract upload |
contract.created | A customer contract is created — dashboard upload or POST /v1/billing/contracts |
Create an endpoint
Dashboard: Settings > Webhooks > Add Endpoint. Enter an HTTPS URL, pick events, and copy the signing secret from the expanded row.
API:
curl -X POST https://api-dev.lucius.finance/v1/webhook-endpoints \
-H "Authorization: Bearer luc_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/lucius/webhooks",
"events": ["customer.created", "contract.created"],
"description": "Production listener"
}'{
"object": "webhook_endpoint",
"id": "8b1f6c2e-4a1d-4e0b-9a51-3f0e2c7d9a10",
"url": "https://example.com/lucius/webhooks",
"description": "Production listener",
"events": ["customer.created", "contract.created"],
"status": "enabled",
"created_at": "2026-10-02T10:00:00.000Z",
"secret": "whsec_3f9c…"
}Store secret — the API only returns it on create and on POST /v1/webhook-endpoints/{id}/rotate-secret. The dashboard can always reveal it.
URLs must be https and resolve to a public address. Redirects are not followed.
Payload
Every event uses the same envelope. data.object matches the shape returned by the API for that resource.
{
"id": "evt_5bc0402ea56ee87323ef79ba",
"object": "event",
"type": "contract.created",
"created_at": "2026-10-02T10:00:00.000Z",
"test": false,
"data": {
"object": {
"object": "contract",
"id": "acme-corp-2026",
"customer_id": "316301ff-ecd8-48cc-a4c4-92c7c6ba33f1",
"customer_name": "Acme Corp",
"contract_number": "CNTRT-001",
"status": "active",
"start_date": "2026-01-01",
"end_date": "2026-12-31",
"currency": "USD",
"metrics": ["domestic_wire", "international_wire"],
"pdf_url": "https://example.blob.vercel-storage.com/contract.pdf",
"created_at": "2026-10-02T10:00:00.000Z"
}
}
}customer.created carries:
{
"object": "customer",
"id": "316301ff-ecd8-48cc-a4c4-92c7c6ba33f1",
"external_id": "cust_acme",
"name": "Acme Corp",
"email": "billing@acme.com",
"domain": "acme.com",
"country": "US",
"created_at": "2026-10-02T10:00:00.000Z"
}external_id is your id (optional on create). id is the Lucius UUID. registration_number is a legal entity number (Companies House / EIN), not a join key.
The contract id is the external ID — the same value you pass as external_contract_id on usage events. metrics is empty for contracts whose pricing program has no usage rule yet.
Headers
| Header | Value |
|---|---|
Lucius-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256> |
Lucius-Event-Id | Same as id in the body |
Lucius-Event-Type | Same as type in the body |
User-Agent | Lucius-Webhooks/1.0 |
Verify the signature
v1 is HMAC-SHA256 of `${t}.${rawBody}` keyed with the endpoint secret. Verify against the raw request body, before JSON parsing.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyLuciusSignature(
rawBody: string,
header: string | null,
secret: string,
toleranceSeconds = 300,
) {
const parts = Object.fromEntries(
(header ?? "").split(",").map((p) => p.split("=") as [string, string]),
);
const t = Number(parts.t);
if (!t || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1);
return a.length === b.length && timingSafeEqual(a, b);
}Example handler (Hono / any Fetch-style runtime):
app.post("/lucius/webhooks", async (c) => {
const raw = await c.req.text();
if (!verifyLuciusSignature(raw, c.req.header("Lucius-Signature"), process.env.LUCIUS_WEBHOOK_SECRET!)) {
return c.text("invalid signature", 400);
}
const event = JSON.parse(raw);
// Dedupe on event.id — retries and resends reuse it
return c.text("ok");
});Responding and retries
Return any 2xx within 10 seconds. Do slow work after responding.
Anything else (non-2xx, timeout, connection error, redirect) is retried with backoff: 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h — 8 attempts total. After that the delivery is marked failed; resend it manually once you are fixed.
Deliveries are at-least-once and can arrive out of order. Use id (Lucius-Event-Id) as your idempotency key.
Testing
- Get a URL. Use a request bin (e.g. webhook.site) to inspect payloads, or tunnel your local server with
ngrok http 3000/cloudflared tunnel --url http://localhost:3000. - Create the endpoint in the sandbox — app-dev or
https://api-dev.lucius.finance. - Send a test event. Row menu → Send test customer.created / contract.created, or:
curl -X POST https://api-dev.lucius.finance/v1/webhook-endpoints/ENDPOINT_ID/test \
-H "Authorization: Bearer luc_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"type":"contract.created"}'{ "id": "…", "status": "succeeded", "status_code": 200, "error": null }Test events are signed like real ones, carry sample data, and set "test": true.
- Trigger real events in the sandbox:
# customer.created
curl -X POST https://api-dev.lucius.finance/v1/billing/customers \
-H "Authorization: Bearer luc_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"Webhook Test Co","email":"billing@example.com","external_id":"cust_webhook_test"}'
# contract.created — sample contract for that customer
curl -X POST https://api-dev.lucius.finance/v1/billing/contracts \
-H "Authorization: Bearer luc_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"customer_id":"CUSTOMER_ID_FROM_ABOVE"}'- Check the delivery log. Expand the endpoint in Settings > Webhooks, or
GET /v1/webhook-endpoints/{id}/deliveries— status, HTTP code, attempts, next retry, and the exact payload. Resend (POST /v1/webhook-endpoints/deliveries/{delivery_id}/resend) re-sends the same event with the sameid.
Managing endpoints
| Action | API |
|---|---|
| List | GET /v1/webhook-endpoints |
| Get | GET /v1/webhook-endpoints/{id} |
| Update URL / events / description | PATCH /v1/webhook-endpoints/{id} |
| Disable / enable | PATCH /v1/webhook-endpoints/{id} with {"enabled": false} |
| Rotate secret | POST /v1/webhook-endpoints/{id}/rotate-secret |
| Delete | DELETE /v1/webhook-endpoints/{id} |
Disabled endpoints receive nothing and their pending retries stop. Rotating the secret takes effect immediately — deploy the new secret first, or briefly accept both.