Lucius API

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:

EventWhen it fires
customer.createdA customer is created — dashboard, POST /v1/billing/customers, or auto-created from an invoice or contract upload
contract.createdA 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

HeaderValue
Lucius-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256>
Lucius-Event-IdSame as id in the body
Lucius-Event-TypeSame as type in the body
User-AgentLucius-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

  1. 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.
  2. Create the endpoint in the sandbox — app-dev or https://api-dev.lucius.finance.
  3. 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.

  1. 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"}'
  1. 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 same id.

Managing endpoints

ActionAPI
ListGET /v1/webhook-endpoints
GetGET /v1/webhook-endpoints/{id}
Update URL / events / descriptionPATCH /v1/webhook-endpoints/{id}
Disable / enablePATCH /v1/webhook-endpoints/{id} with {"enabled": false}
Rotate secretPOST /v1/webhook-endpoints/{id}/rotate-secret
DeleteDELETE /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.

On this page