# LexSelect Webhooks

Receive signed notifications when your documents are queued, finish processing or fail. You manage webhook endpoints in **Studio → Webhooks**.

[Open the full API reference](/api/docs) ·
[Download this guide as Markdown](/api/webhooks.md) ·
[Download the Node.js signature verifier](/api/verify-webhook.mjs)

## Integration flow

1. In Studio, create and verify a webhook endpoint under the account that uploads your documents. Subscribe to `document.processing.completed` and `document.processing.failed`.
2. Upload with `POST /documents/upload` and save the returned document `id`.
3. When a delivery arrives, verify its signature against the **raw request bytes**, store or enqueue the event, then return `2xx`.
4. On `document.processing.completed`, call `GET /documents/{id}/parse` with `data.document_id` and an API key from the same account, and save the results in your system. Events carry identifiers and status, not document content.
5. Optionally call `DELETE /documents/{id}` for a document your integration uploaded, once you have saved everything you need. Check the ID against the uploads you recorded: `completed` and `failed` events also arrive for documents processed in Studio, so never delete because of an event alone.
6. Alongside the receiver, run the reconciliation sweep from [Operations and limits](/api/operations.html) every few minutes. It picks up documents whose events you missed and runs that were canceled, which send no event.

**Email uploads.** An EML or MSG upload is processed as one run, and the event is sent once, for the uploaded email. The email body and each attachment become child documents with their own pages: list them with `GET /documents?parent_id={id}` and call `/parse` for each child. The uploaded email itself has no pages, and deleting it also deletes its children. See [Understand output](/api/understanding-output.html).

**Retention.** API uploads are deleted 24 hours after upload or 1 hour after processing ends, whichever comes first. Fetch results as soon as the completed event arrives. A delivery that succeeds only on a later retry, or one you resend with **Retry delivery**, can name a document that has already been deleted, so the sweep is the reliable path for results.

**Ownership.** Webhook endpoints belong to an account. An endpoint receives events for documents owned by the account that created it. `document.processing.queued` is sent for API uploads; `completed` and `failed` are also sent for documents the account processes in Studio. Other workspace members' events never reach your endpoint. Use separate accounts and endpoints for development and production.

Event payloads are the same whatever `X-API-Version` you send. Webhook deliveries do not count toward your API rate limits; your receiver's calls back to the API, such as `/parse`, do.

## Set up an endpoint

1. In Studio, open **Webhooks**, choose **Create webhook**, enter a public HTTPS receiver URL that accepts `POST`, and select the events you need.
2. Copy the complete signing secret, including the `whsec_` prefix. It is shown only once. Store it on your server, separately from your `lxs_` API key.
3. Open **Verify domain**, add the TXT record that Studio shows at `_lexselect-webhook.<receiver-hostname>`, then choose **Check DNS**. DNS changes can take a while to propagate. If your account already has a verified endpoint on the same hostname, a new endpoint on that hostname is verified straight away.
4. Once the endpoint is verified and active, it receives real events. Changing only the URL path keeps the verification; a different hostname needs its own verification.

The receiver URL must use HTTPS on a public hostname with a valid certificate; any port works. Localhost, private addresses and redirects cannot receive deliveries. **Send Test** works before domain verification, so a public HTTPS tunnel is enough to test signatures; to receive real events through a tunnel, use a hostname whose DNS records you control.

Deliveries don't come from a published set of IP addresses, so authenticate each delivery by its signature rather than by its source address.

An endpoint keeps its signing secret for its whole life. To change secrets, create a second endpoint with the same events, accept both secrets in your receiver while you switch, then delete the old endpoint. Both endpoints send the same event IDs, so deduplication keeps working during the switch.

## Events and payloads

| Event and when it is sent | `data` fields |
|---|---|
| `document.processing.queued`: an API upload was accepted for processing | `document_id`, `document_name`, `status: "queued"` |
| `document.processing.completed`: processing completed successfully | `document_id`, `document_name`, `processing_id`, `status: "completed"` |
| `document.processing.failed`: processing failed | `document_id`, `document_name`, `processing_id`, `status: "failed"`, `error_code`, `error_message`, `failed_task` |
| `*`: subscription to every event type, including future ones | — |

On a failed event, branch on `error_code`; [Operations and limits](/api/operations.html) lists the values. `error_message` is a readable explanation, and `failed_task` is always `processing`. For progress and page counts, call `GET /documents/{id}/processing/latest`.

Every event uses this envelope:

```json
{
  "id": "72c46026-d682-4325-ae72-e2c09db7d525",
  "type": "document.processing.completed",
  "created_at": "2026-10-05T17:42:00.000000+00:00",
  "data": {
    "document_id": "6650c0ffee0000000000a001",
    "document_name": "8f0c2d4e-5b7a-4e1f-9c3d-2a6b8e0f1c47.pdf",
    "processing_id": "6650c0ffee0000000000a002",
    "status": "completed"
  }
}
```

- `id` is the event ID. It matches the `X-Webhook-Id` header and stays the same across every attempt and retry, so use it to deduplicate.
- `created_at` is when the event was created. It stays the same on retries.
- `document_name` is the stored name. It is generated unless the upload sent `strip_file_name=false`, so correlate by ID rather than by name.

Ignore fields you do not recognize, and accept event types you do not handle without failing.

## Delivery headers

| Header | Meaning |
|---|---|
| `Content-Type: application/json` | UTF-8 JSON event body |
| `X-Webhook-Id` | The event ID, the same as the body's `id` |
| `X-Webhook-Timestamp` | Unix time in seconds at which this attempt was signed |
| `X-Webhook-Signature` | `sha256=` followed by the 64 lowercase hexadecimal characters of the HMAC-SHA256 digest |
| `User-Agent: LexSelect-Webhooks/1.0` | Identifies the sender; authenticate deliveries by their signature |

Header names are case-insensitive.

## Verify signatures

Use the **entire signing secret as UTF-8 text**, including `whsec_`; don't strip the prefix or decode the secret. The signed message is:

`UTF8(X-Webhook-Timestamp + ".") || exact raw request body bytes`

Compute HMAC-SHA256 and compare the digest bytes in constant time before parsing the JSON: re-serialized JSON does not match the signature. This Node.js 20+ module uses only the standard library:

```js
// file: verify-webhook.mjs
import { createHmac, timingSafeEqual } from 'node:crypto';

/** Verify one delivery without parsing or changing its signed body bytes. */
export function verifyWebhook(
  rawBody,
  headers,
  signingSecret,
  nowSeconds = Math.floor(Date.now() / 1000),
  toleranceSeconds = 300
) {
  const timestamp = headers['x-webhook-timestamp'];
  const signature = headers['x-webhook-signature'];
  if (
    !Buffer.isBuffer(rawBody) ||
    typeof signingSecret !== 'string' ||
    !signingSecret.startsWith('whsec_') ||
    typeof timestamp !== 'string' ||
    !/^\d{1,12}$/.test(timestamp) ||
    typeof signature !== 'string' ||
    !/^sha256=[0-9a-f]{64}$/.test(signature) ||
    !Number.isSafeInteger(nowSeconds) ||
    !Number.isSafeInteger(toleranceSeconds) ||
    toleranceSeconds < 0
  ) return false;
  const signedAt = Number(timestamp);
  if (Math.abs(nowSeconds - signedAt) > toleranceSeconds) return false;
  const expected = createHmac('sha256', Buffer.from(signingSecret, 'utf8'))
    .update(timestamp + '.', 'utf8')
    .update(rawBody)
    .digest();
  const received = Buffer.from(signature.slice('sha256='.length), 'hex');
  return received.length === expected.length && timingSafeEqual(received, expected);
}
```

Pass the unmodified `Buffer` read from the request stream. In Express, mount `express.raw({ type: 'application/json' })` on the webhook route before any JSON body parser. After verifying, parse the JSON and check that `payload.id` matches `X-Webhook-Id`.

The example accepts timestamps up to five minutes either side of your clock. Keep your server clock synchronized, and adjust the tolerance if you need to. Check the attempt timestamp, not `created_at`: every retry is signed again with a fresh timestamp but keeps the original body.

## Acknowledge and deduplicate

Store or enqueue the event, then return `2xx` within 10 seconds, and do slow work such as `/parse` in your own worker. A `3xx` redirect is not followed and counts as a failed attempt. If you could not store the event, return an error so that the delivery is retried.

Delivery is at-least-once and unordered. Record each event `id` under a unique constraint, and acknowledge duplicates without repeating their effects.

## Retries and endpoint health

- A delivery is attempted up to five times: the first send, then retries after about 30 seconds, 2 minutes, 15 minutes and 1 hour. A `Retry-After` header from your receiver does not change this schedule.
- Every non-`2xx` response, timeout, connection failure and DNS failure is retried. Deliveries to unsafe or private addresses are refused rather than retried.
- Retries normally finish within about 80 minutes of the event. If delivery is delayed, for example during an outage, automatic delivery stops 24 hours after the event was created.
- After 10 consecutive deliveries fail their final attempt, the endpoint is disabled. A successful delivery resets the count.
- **Pause** in Studio stops real deliveries to an endpoint until you choose **Resume**. A disabled endpoint stays disabled until you choose **Re-enable**, which keeps the signing secret and resets the failure count. Events created while an endpoint is paused or disabled are not sent later; the reconciliation sweep picks up those documents.

**Retry delivery** in Studio resends one failed delivery from the last 30 days, including after its automatic window has closed. It sends the original body and event ID to the endpoint's current URL, signed with the current secret and a fresh timestamp. The endpoint must be active and verified, and a delivery whose record has expired cannot be retried. The document it names may already have been deleted by retention, so check `/processing/latest` before you rely on it.

**Test URL**, **Send Test** and **Retry delivery** are each limited to five uses per account per minute.

## Send Test and Test URL

**Send Test** sends a signed test event to a saved endpoint, including before domain verification and while the endpoint is paused or disabled. It is sent once, is not retried, and does not change the endpoint's status or failure count. A test event has the real envelope and the same `data` keys as the real event of its type, with the document and processing ID `000000000000000000000000`, `document_name: "test-document.pdf"` and an extra `message: "This is a test webhook event"`. Have your handler recognize the zero document ID and skip fetching results for it. Besides the real event types, Send Test can send two test-only types, `document.processing.started` and `document.processing.page_completed`, which real processing does not send.

**Test URL** checks connectivity before you save an endpoint. It sends this unsigned request:

```json
{"type":"lexselect.webhook.probe","data":{"message":"Unsigned connectivity test; no webhook has been saved."}}
```

The request carries `X-LexSelect-Test: true` and `User-Agent: LexSelect-Webhook-Probe/1.0`, and has no signature, timestamp or event ID. A receiver that enforces signatures answers it with `401`, which is expected; use **Send Test** to check signatures. Test URL requires public HTTPS on port 443 with no credentials or fragment in the URL, and does not follow redirects.

## Troubleshooting

| Symptom | What to check |
|---|---|
| Receiver returns `401` or `403` | The saved secret, raw-body handling, your server clock, and any proxy or firewall in front of the receiver. A `401` to **Test URL** is expected. |
| Receiver returns `404` | The hostname, `POST` route, path prefix and tunnel routing. This status comes from your receiver. |
| `/parse` returns `404` | Use `data.document_id` with an API key from the same account, and check whether the document was deleted or reached its retention deadline. |
| `/parse` returns no pages for an email | Parse the child documents listed by `GET /documents?parent_id={id}`. |
| No real deliveries | Account ownership, the event subscription, the endpoint status and domain verification. |
| DNS, TLS or timeout failures | Public DNS, the HTTPS certificate, reachability and a prompt `2xx`. |
| Endpoint disabled | Fix the receiver, choose **Re-enable**, then use **Retry delivery** for the failures you need. |
| Duplicate deliveries | Deduplicate by event ID; retries keep the same ID. |

**Webhooks → View in Logs**, or **Logs**, in Studio shows each delivery's status, latency and error. Payload previews can be truncated or redacted. Workspace owners can also open **Workspace logs** to see other members' delivery metadata, without payloads. When you contact [support@lexselect.io](mailto:support@lexselect.io), include event IDs and approximate times, and never share your signing secret.
