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

# External Webhooks

> Get notified the moment a connection syncs, fails, or needs re-authentication - instead of polling Leen

## Overview

External webhooks let Leen call you. Register an HTTPS endpoint, subscribe it to the events you
care about, and Leen POSTs a JSON envelope to that endpoint whenever one of them happens.

The usual reason to use them is to stop polling. Instead of asking "has this connection synced
yet?" on a timer, you receive `connection.sync.succeeded` and pull the new data once, when there
actually is new data.

<Note>
  This page is about **external** webhooks - Leen calling your service. That is a different feature
  from [Third-Party Webhooks](/webhooks/webhooks-reference), where a vendor such as JIRA calls Leen
  to push data in. The two do not interact.
</Note>

## Events

| Event | Fires when |
| - | - |
| `connection.create.succeeded` | A connection was created and its row was committed. |
| `connection.create.failed` | A connection could not be created. |
| `connection.sync.succeeded` | A connection sync finished successfully - new data is ready. |
| `connection.sync.failed` | A sync failed and the failure needs action from you. |
| `connection.sync.unauthorized` | The vendor rejected a connection's credentials. It must be reconnected. |
| `webhook.ping` | Test event sent by the test endpoint. |

The **Add endpoint** dialog in the portal lists every event with its description, and is always
current - check there rather than relying on the table above.

## Registering an endpoint

Webhook endpoints are managed from the Leen portal, under **Settings → Webhooks**.

<Steps>
  <Step title="Open Settings > Webhooks">
    Each row is one endpoint: where it points, which events it receives, and whether it is
    currently enabled.

    <img src="https://mintcdn.com/leen/3gJQjVY_uM7TmN-4/images/webhooks/endpoints-list.png?fit=max&auto=format&n=3gJQjVY_uM7TmN-4&q=85&s=ea6bff3349d7a46b5b1cce1c3a376717" alt="The Webhooks tab in Leen settings, listing registered endpoints with their URL, auth type, subscribed events, and enabled state" width="1512" height="795" data-path="images/webhooks/endpoints-list.png" />
  </Step>

  <Step title="Add an endpoint">
    Click **Add Endpoint**, then give it a name and an HTTPS URL and tick the events you want.
    Every subscribable event is listed with a description of exactly when it fires.

    <img src="https://mintcdn.com/leen/3gJQjVY_uM7TmN-4/images/webhooks/create-dialog.png?fit=max&auto=format&n=3gJQjVY_uM7TmN-4&q=85&s=58b5af57c8e372a21a7484702b6a78b2" alt="The Add webhook endpoint dialog: name and URL fields, a note that deliveries are signed with HMAC-SHA256, and the list of subscribable events with descriptions" width="485" height="541" data-path="images/webhooks/create-dialog.png" />

    There is no authentication to choose: every delivery is signed with HMAC-SHA256.
  </Step>

  <Step title="Store the signing secret">
    Leen generates a signing secret and shows it exactly once, on save.

    <img src="https://mintcdn.com/leen/3gJQjVY_uM7TmN-4/images/webhooks/signing-secret.png?fit=max&auto=format&n=3gJQjVY_uM7TmN-4&q=85&s=b2097061c3820776a7921157f3f1a320" alt="The signing secret dialog, warning that the secret is shown only once and cannot be retrieved again, only rotated" width="820" height="664" data-path="images/webhooks/signing-secret.png" />

    <Warning>
      Copy it before you close this dialog. The secret is never displayed again and there is no
      endpoint that returns it. If you lose it, your only option is **Rotate secret**, which
      issues a new one and invalidates the old.
    </Warning>
  </Step>

  <Step title="Send a test event and check the result">
    **Send test** queues a `webhook.ping` to that endpoint alone - the other endpoints
    subscribed to `webhook.ping` do not receive it. The endpoint must be enabled **and**
    subscribed to `webhook.ping`, or nothing is sent.

    Delivery is asynchronous, so open the endpoint to watch the outcome. The **Deliveries** table
    records every attempt with its status, attempt count, and the HTTP code your endpoint
    returned - a failed signature check shows up here as a `401` without you adding any logging.

    <img src="https://mintcdn.com/leen/3gJQjVY_uM7TmN-4/images/webhooks/endpoint-detail.png?fit=max&auto=format&n=3gJQjVY_uM7TmN-4&q=85&s=710ee105deb8a9645868333c9ba2cf26" alt="An endpoint's detail view: URL, subscribed events and description, above a Deliveries table listing each attempt with status, attempt count, response code and timestamps" width="1512" height="795" data-path="images/webhooks/endpoint-detail.png" />

    Filter by status or event type to find a specific failure. The `401` and `404` rows above are
    permanent rejections - neither is retried, so both stop at a single attempt. The row with 3
    attempts is a `500` that was retried and then accepted.
  </Step>
</Steps>

### URL requirements

* **HTTPS only.** Plain HTTP is rejected.
* **Publicly resolvable.** URLs that resolve to a private, loopback, link-local, reserved,
  multicast, or unspecified address are rejected. This is an SSRF guard.
* **Re-checked before every delivery**, not just at registration - a hostname that resolves
  publicly today but is later re-pointed at an internal address stops receiving deliveries.
* **2048 characters** maximum.

## What Leen sends

A single JSON object, POSTed as `application/json`:

```json theme={null}
{
  "id": "5e1cb1e0-3a0f-4e33-9a55-7a3d5c2f0f11",
  "type": "connection.sync.succeeded",
  "created_at": "2026-08-17T09:14:02.481Z",
  "environment_id": "b0a1c2d3-4e5f-6789-abcd-ef0123456789",
  "organization_id": "1a2b3c4d-5e6f-7890-abcd-ef0123456789",
  "connection_id": "9c8b7a65-4321-4321-8765-0fedcba98765",
  "data": {
    "vendor": "CROWDSTRIKE",
    "error": null,
    "description": null
  }
}
```

`id`, `type`, `created_at`, and `environment_id` are on every event. `connection_id` is on every
`connection.*` event - it is at the root, not inside `data`, so you can route on it without
knowing the event's payload shape. It is `null` in exactly one case: a `connection.create.failed`
raised before the connection existed at all.

`data` is where the event-specific fields live. Every `connection.*` event carries the same three:

| Field | Meaning |
| - | - |
| `vendor` | The connection's vendor, e.g. `CROWDSTRIKE`. |
| `error` | A stable failure code, or `null` on a success event. Branch on this. |
| `description` | The human-readable sentence behind `error`. Log it, do not parse it. |

Keys are serialized in alphabetical order, and the payload may grow new keys - so parse it as
JSON, and ignore fields you do not recognize rather than rejecting them.

### Error codes

`error` is a fixed set. The text in `description` may be reworded at any time; these codes will
not be.

| Code | Fires on | What it means |
| - | - | - |
| `UNAUTHORIZED` | `connection.sync.unauthorized` | The vendor rejected the stored credentials. The connection must be reconnected. |
| `CONNECTION_RECONFIGURATION_REQUIRED` | `connection.sync.failed` | The connection's configuration no longer works - e.g. a permission or scope it needs was removed. |
| `CONNECTION_CREATE_FAILED` | `connection.create.failed` | The connection could not be created. `description` carries the reason. |

### Headers

| Header | Sent on | Meaning |
| - | - | - |
| `User-Agent` | every delivery | Always `Leen-Webhooks/1` |
| `X-Leen-Webhook-Id` | every delivery | Which of your registered endpoints this is |
| `X-Leen-Event-Id` | every delivery | Identifies the **event**, not the attempt - see below |
| `X-Leen-Event-Type` | every delivery | e.g. `connection.sync.succeeded` |
| `X-Leen-Timestamp` | every delivery | Unix seconds, and part of the signed payload |
| `X-Leen-Signature` | every delivery | `v0=sha256=<hex digest>` |

## Delivery, retries, and duplicates

A delivery counts as successful on any **2xx**. Return one as soon as you have durably accepted
the event - do your real work afterwards, not before, or slow processing will read as a failure.

* **Timeout: 15 seconds** per attempt.
* **Retried:** 5xx, plus `408` and `429`.
* **Not retried:** any other 4xx. A `400` or `401` is treated as a permanent rejection and the
  delivery is marked failed immediately.
* **5 attempts** with exponential backoff - roughly 10s, 30s, 90s, then 4.5m after the first,
  so the last attempt lands about 7 minutes in.

<Warning>
  **Design for duplicates.** `X-Leen-Event-Id` is generated once per event and reused across every
  retry *and* across every endpoint the event fans out to. It is the deduplication key - store it
  and ignore an id you have already processed. Delivery is at-least-once, never exactly-once.
</Warning>

## Authentication

Every delivery is signed with HMAC-SHA256. There is no unauthenticated mode and nothing to choose
at registration - Leen signs each request with a secret only the two of you know, so you can prove
the request really came from Leen and that nobody altered it in transit.

The signature is hex HMAC-SHA256 over `{timestamp}.{body}`, sent as:

```
X-Leen-Signature: v0=sha256=3f8c...
X-Leen-Timestamp: 1755421442
```

To verify: split the header on `=`, confirm the version and algorithm are `v0` and `sha256`,
recompute the digest over the **raw request body**, and compare in constant time. Then bound the
age of `X-Leen-Timestamp` - 5 minutes is the recommended tolerance - which is what stops someone
replaying a request they captured earlier.

<Warning>
  Sign the **raw bytes you received**. Parsing the JSON and re-serializing it produces different
  bytes and therefore a different digest, and your verification will fail for reasons that are
  very hard to see. In most frameworks this means explicitly asking for the raw body rather than
  the parsed one.
</Warning>

Working code for this is on the [Receiver Example](/webhooks/receiver-example) page.

### Rotating the signing secret

**Rotate secret** on the endpoint's detail page issues a new secret and shows it once, under the
same rule as the original. Deliveries already in flight are signed with whichever secret is
current at the moment the attempt is made, so a rotation can land between the original attempt and
its retry. To rotate without dropping events, accept **either** secret for a few minutes, then
drop the old one.

## Managing endpoints

Each row on **Settings → Webhooks** carries the actions for that endpoint:

| Action | What it does |
| - | - |
| **Enabled** toggle | Pauses or resumes deliveries |
| **View** | Opens the detail view with the delivery history |
| **Edit** | Change the name, URL, subscribed events, or description |
| **Send test** | Queues a `webhook.ping` |
| **Rotate secret** | Issues a new signing secret, shown once |
| **Delete** | Removes the endpoint |

Turning an endpoint **off** stops deliveries without losing its configuration or its secret, which
is what you want during your own deploys - preferable to deleting and re-adding, since re-adding
issues a new secret.

## Troubleshooting

<AccordionGroup>
  <Accordion title="Signature mismatch on every request">
    Almost always re-serialized JSON. Verify against the raw request body, before any parsing.
    Confirm you are using the secret from *this* endpoint, and that it has not been rotated since.
  </Accordion>

  <Accordion title="The test event never arrives">
    **Send test** silently sends nothing unless the endpoint is enabled **and** subscribed to
    `webhook.ping`. Confirm both, then check the **Deliveries** table - a failed attempt records
    your status code and response body.
  </Accordion>

  <Accordion title="Deliveries stopped without any change on your side">
    The URL is re-validated before every attempt. If DNS for the host now resolves to a private or
    non-routable address, deliveries are refused by design. Also check the endpoint has not been
    disabled.
  </Accordion>

  <Accordion title="You are getting the same event twice">
    Expected. Deduplicate on `X-Leen-Event-Id`, which is stable across retries and across
    endpoints.
  </Accordion>

  <Accordion title="Connections go quiet instead of reporting a failure">
    Two by-design cases: failures Leen or the vendor owns raise nothing at all, and expired
    credentials raise `connection.sync.unauthorized` rather than `connection.sync.failed`.
    Subscribe to the unauthorized event.
  </Accordion>
</AccordionGroup>
