> ## Documentation Index
> Fetch the complete documentation index at: https://docs.lucenthq.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Receive HTTP callbacks from Lucent when issues and other events happen in your organization.

Webhooks let Lucent push events to your service in real time, instead of you
polling the [Data API](/api-reference/data-api). When an event fires — for
example, a new issue is created — Lucent makes a signed `POST` to the URL you
register, with a JSON body describing the event.

## Available events

| Event           | When it fires                                                |
| --------------- | ------------------------------------------------------------ |
| `issue.created` | A new issue is created from session analysis or AI insights. |

More event types (`issue.status_changed`, `issue.recurred`, `signal.matched`,
`insight.generated`) are on the roadmap.

## Register an endpoint

Webhooks are configured per organization, in [**Organization → Webhooks**](https://app.lucenthq.com/organization).

<Steps>
  <Step title="Open the Webhooks section">
    In the dashboard, go to [**Organization → Webhooks**](https://app.lucenthq.com/organization)
    and click **Add endpoint**.
  </Step>

  <Step title="Set the URL and events">
    Enter:

    * a **name** (max 64 characters)
    * the **URL** Lucent should `POST` to (HTTPS, max 2048 characters)
    * the **events** you want delivered to this endpoint
  </Step>

  <Step title="Copy the signing secret">
    On save, Lucent shows the signing secret once. It looks like
    `whsec_…`. Store it somewhere safe (a secret manager, your `.env`).
    The dashboard only ever shows the prefix (`whsec_xxxx…`) again — there is
    no way to retrieve the full secret later. If you lose it, revoke the
    endpoint and create a new one.
  </Step>

  <Step title="Send a test event">
    Use **Send test** to deliver a synthetic event of the type you chose. Test
    events carry the `Lucent-Webhook-Test: 1` header so you can branch on them
    in your handler.
  </Step>
</Steps>

### Limits

* Up to **3 active endpoints per organization**.
* Up to **5 endpoint creations per hour** per organization.
* Test event sends are rate-limited per organization and per endpoint.

Webhooks are available on paid plans.

## Payload shape

Every delivery is a single JSON object with stable top-level fields:

| Field        | Type     | Description                                                                                  |
| ------------ | -------- | -------------------------------------------------------------------------------------------- |
| `event`      | `string` | The event type, e.g. `issue.created`.                                                        |
| `id`         | `uuid`   | Envelope id. Identical across every retry of the same event — use it to dedupe on your side. |
| `occurredAt` | `string` | ISO 8601 timestamp of when the event happened in Lucent.                                     |
| `data`       | `object` | Event-specific payload. The shape depends on `event`.                                        |

### `issue.created`

```json theme={null}
{
  "event": "issue.created",
  "id": "ea0c8c0b-4f3d-4b1c-9e7a-d4b1c8e7a4f3",
  "occurredAt": "2026-05-12T08:42:13.512Z",
  "data": {
    "issueId": "9b1c0f8b-a4e2-4ff7-8c5b-7d8a9e2f4c1b",
    "orgId": "00000000-0000-0000-0000-000000000000",
    "title": "Checkout button unresponsive on mobile",
    "description": "Multiple sessions show the checkout button failing to register taps...",
    "status": "unresolved",
    "priority": "high",
    "previewUrl": null,
    "aiVerified": true,
    "sourceType": "session_analysis"
  }
}
```

| `data` field  | Type              | Notes                                                                                              |
| ------------- | ----------------- | -------------------------------------------------------------------------------------------------- |
| `issueId`     | `uuid`            | The issue id. Use with [`GET /api/v1/issues/{issueId}`](/api-reference/get-an-issue).              |
| `orgId`       | `uuid`            | The Lucent organization that owns the issue.                                                       |
| `title`       | `string`          | Short human-readable summary.                                                                      |
| `description` | `string \| null`  | Long-form summary, truncated to 1,000 characters with `...` if longer.                             |
| `status`      | `string`          | One of `unresolved`, `ticket_created`, `transient`, `resolved`.                                    |
| `priority`    | `string`          | One of `critical`, `high`, `medium`, `low`.                                                        |
| `previewUrl`  | `string \| null`  | URL to a preview frame of the offending session, if available.                                     |
| `aiVerified`  | `boolean \| null` | Whether AI verification has run and confirmed the issue.                                           |
| `sourceType`  | `string`          | `session_analysis` (issue derived from a session) or `insight` (issue derived from an AI insight). |

## Headers

| Header                | Example                                | Notes                                                                                  |
| --------------------- | -------------------------------------- | -------------------------------------------------------------------------------------- |
| `Lucent-Signature`    | `t=1715500000,v1=3c35a6b4…`            | HMAC-SHA256 over `${t}.${rawBody}`. See [Verify the signature](#verify-the-signature). |
| `Lucent-Webhook-Id`   | `ea0c8c0b-4f3d-4b1c-9e7a-d4b1c8e7a4f3` | Same value as the body's `id`. Identical across all retries — persist for dedup.       |
| `Lucent-Event`        | `issue.created`                        | The event type. Same value as the body's `event`.                                      |
| `Content-Type`        | `application/json`                     |                                                                                        |
| `User-Agent`          | `Lucent-Webhooks/1.0`                  |                                                                                        |
| `Lucent-Webhook-Test` | `1`                                    | Only present on test events sent from the dashboard.                                   |

## Verify the signature

Lucent signs every delivery using a Stripe-compatible scheme: HMAC-SHA256 over
`${timestamp}.${rawBody}`, using your endpoint's signing secret as the key.

The `Lucent-Signature` header is a comma-separated key/value list:

```text theme={null}
Lucent-Signature: t=1715500000,v1=3c35a6b4d9e2f0...
```

* `t` — Unix timestamp (seconds) of when Lucent generated the signature.
* `v1` — hex-encoded HMAC-SHA256.

To verify:

1. Read the **raw** request body. Sign the bytes Lucent sent — re-serializing
   parsed JSON will not match.
2. Recompute `HMAC_SHA256(secret, "${t}.${rawBody}")`.
3. Compare with `v1` in constant time.
4. Reject the request if `t` is more than 5 minutes from your server clock —
   this prevents replay of an old, valid request.

<CodeGroup>
  ```ts Node.js theme={null}
  import crypto from "node:crypto";

  function verifyLucentSignature(
    rawBody: string,
    signatureHeader: string,
    secret: string,
  ) {
    // signatureHeader looks like: "t=1715500000,v1=3c35a6b4..."
    const parts = Object.fromEntries(
      signatureHeader.split(",").map((p) => {
        const [k, v] = p.split("=", 2);
        return [k, v ?? ""];
      }),
    );
    const t = parts.t;
    const v1 = parts.v1;
    if (!t || !v1) return false;

    // Reject replays older than 5 minutes.
    const ageSec = Math.floor(Date.now() / 1000) - Number(t);
    if (Number.isNaN(ageSec) || Math.abs(ageSec) > 300) return false;

    const expected = crypto
      .createHmac("sha256", secret)
      .update(`${t}.${rawBody}`)
      .digest("hex");

    // timingSafeEqual throws if buffers differ in length — guard first.
    const expectedBuf = Buffer.from(expected);
    const v1Buf = Buffer.from(v1);
    if (expectedBuf.length !== v1Buf.length) return false;
    return crypto.timingSafeEqual(expectedBuf, v1Buf);
  }
  ```

  ```python Python theme={null}
  import hmac
  import hashlib
  import time

  def verify_lucent_signature(raw_body: str, signature_header: str, secret: str) -> bool:
      # signature_header looks like: "t=1715500000,v1=3c35a6b4..."
      parts = {}
      for segment in signature_header.split(","):
          if "=" not in segment:
              continue
          k, v = segment.split("=", 1)
          parts[k] = v
      t = parts.get("t")
      v1 = parts.get("v1")
      if not t or not v1:
          return False

      # Reject replays older than 5 minutes.
      try:
          t_int = int(t)
      except ValueError:
          return False
      if abs(int(time.time()) - t_int) > 300:
          return False

      signed = f"{t}.{raw_body}".encode()
      expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
      # compare_digest is naturally length-tolerant.
      return hmac.compare_digest(expected, v1)
  ```
</CodeGroup>

<Warning>
  Read the raw request body before any JSON parsing or middleware that might
  rewrite it. In Express, mount `express.raw({ type: "application/json" })` for
  the webhook route. In Next.js Route Handlers, use `await request.text()`.
</Warning>

## Retries and idempotency

* Delivery has a **10-second per-request timeout**.
* Non-2xx responses, timeouts, and network errors trigger a **retry** — up to
  **3 retries** with exponential backoff after the initial attempt.
* The same `Lucent-Webhook-Id` is sent on every attempt. Persist it on success
  and treat duplicate ids as no-ops.
* Lucent recomputes the signature on each retry, so `t` (and the
  `Lucent-Signature` value) will differ between attempts. The body bytes and
  `Lucent-Webhook-Id` stay the same.
* Because the timeout is 10 seconds, your handler should **acknowledge fast**:
  validate the signature, enqueue the work, and return `200`. Do the actual
  processing asynchronously.

## Security model

Webhook URLs go through several checks before every delivery, not just at
creation:

* **HTTPS only.** Plain `http://` URLs are rejected. URLs with `user:pass@`
  credentials are also rejected — `fetch` would silently send them as Basic
  auth.
* **Public destinations only.** The hostname is resolved fresh on every
  delivery and its IPs are checked against `ipaddr.js` ranges. Loopback,
  private, link-local, and other non-unicast addresses are rejected. This
  defends against DNS rebinding (an attacker-controlled hostname pointing at
  `127.0.0.1` between checks).
* **Service ports are blocked.** Common service ports — SSH (22), SMTP (25),
  Postgres (5432), MySQL (3306), Redis (6379), Mongo (27017), Elasticsearch
  (9200/9300), Memcached (11211), Docker (2375/2376), and others — are
  rejected so webhooks can't be used to probe internal infrastructure.
* **Redirects are not followed.** Set the final URL on your endpoint directly.

If a delivery is blocked for any of these reasons, the endpoint's last delivery
status is recorded as `url_unsafe` and no request leaves Lucent.

## Endpoint management

Each endpoint surfaces, in [**Organization → Webhooks**](https://app.lucenthq.com/organization):

* last delivery time and HTTP status
* last delivery error label, if any (`bad_status:NNN`, `timeout`, `tls_error`,
  `network_error`, `url_unsafe`)
* a **Send test** button
* a **Revoke** button — revoked endpoints stop receiving deliveries
  immediately and free a slot against the per-org cap.

The full signing secret is shown only at creation. The dashboard always shows
the prefix (`whsec_xxxx…`) so you can tell endpoints apart without exposing
the secret.
