> This page is for version v1 (default).
> For other versions, use one of these documentation indexes:
> - v1 (default): https://docs.plextera.com/v-1/llms.txt

> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.plextera.com/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.plextera.com/_mcp/server.

# Event Subscriptions

Event subscriptions let Plextera push product events to your webhook endpoint. Use them when your integration should react to completed, failed, or rejected processing without polling.

## When to use events

| Pattern             | Recommended for                                                                                        |
| ------------------- | ------------------------------------------------------------------------------------------------------ |
| Polling only        | Simple backend integrations, local testing, or flows where delayed processing is acceptable.           |
| Events only         | Production integrations that need push notifications and lower API traffic.                            |
| Events plus polling | Robust production integrations. Use events for the normal path and polling as a fallback status check. |

> **Tip**
>
> A common production pattern is: subscribe to terminal events, process webhook deliveries immediately, and periodically poll resources that have not completed after an expected time window.

## Available events

| Event                                    | Delivered when                                                               | Payload                              |
| ---------------------------------------- | ---------------------------------------------------------------------------- | ------------------------------------ |
| `document-insights.extraction.completed` | A document extraction reaches `COMPLETED`.                                   | Full extraction with `output`.       |
| `document-insights.extraction.failed`    | A document extraction reaches `FAILED`.                                      | Extraction state with `error`.       |
| `document-insights.extraction.rejected`  | A document extraction reaches `REJECTED`.                                    | Extraction state with `error`.       |
| `workflow.run.completed`                 | A workflow run reaches `COMPLETED`.                                          | Full workflow run with step outputs. |
| `workflow.run.failed`                    | A workflow run reaches `FAILED`, or is closed in Plextera Studio (`CLOSED`). | Workflow run state with `error`.     |

> **Info**
>
> Closing a run in Plextera Studio is treated as a failure outcome for event delivery: subscribers of `workflow.run.failed` receive an event whose run `status` is `CLOSED`. Check the `status` field in the payload if your handler needs to distinguish the two.

## Setup

### Create an event subscription

Call `POST /event-subscriptions` with:

* `name` - an optional display name for the subscription (maximum 128 characters).
* `endpointUrl` - the HTTPS URL Plextera should POST events to (maximum 2,048 characters; credentials and fragments are not allowed).
* `eventTypes` - one or more event types to subscribe to (maximum 16).
* `filters` - optional filters for workflow events, for example a specific workflow ID (maximum 256 characters).

Plextera generates the signing secret.
The `201 Created` response returns it once together with the new active subscription.
Store the value securely while handling the response and configure it in your webhook receiver.

Each workspace can have up to 50 event subscriptions.
Delete an unused subscription before creating another one after reaching this limit.
Create subscription returns `409 CONFLICT` when the quota is already exhausted.

```bash
curl -X POST https://api.plextera.com/api/public/v1/event-subscriptions \
  -H "Authorization: api-key YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Document extraction events",
    "endpointUrl": "https://example.com/webhooks/plextera",
    "eventTypes": [
      "document-insights.extraction.completed",
      "document-insights.extraction.failed",
      "document-insights.extraction.rejected"
    ]
  }'
```

**`201 Created`**

```json title="201 Created"
{
  "id": "sub_01JY7N4MT3JQW4TB7B8R5K2E6A",
  "name": "Document extraction events",
  "status": "active",
  "endpointUrl": "https://example.com/webhooks/plextera",
  "eventTypes": [
    "document-insights.extraction.completed",
    "document-insights.extraction.failed",
    "document-insights.extraction.rejected"
  ],
  "createdAt": "2026-07-23T10:05:04Z",
  "updatedAt": "2026-07-23T10:05:04Z",
  "signingSecret": "whsec_Q3E2w8rQ0qH8w0yLq3bN5JWd4mY1uWz22H0Qw0Y9G5A"
}
```

Document Insights subscriptions rarely need `filters` - the event types already select what is delivered. The response omits `filters` when none are configured.

### Implement your webhook endpoint

Your endpoint must:

* Accept `POST` requests with a JSON body.
* Read the raw request body for signature verification.
* Return a `2xx` status within 15 seconds - acknowledge first, then process the event asynchronously. Slow responses time out and count as failed deliveries.
* Handle duplicate deliveries safely.

### Verify the signature

Verify the `X-Plextera-Signature` header before trusting the event payload.

## Event payload model

Every event uses a common envelope:

```json
{
  "eventId": "evt_01JY7M9QWBWCPMZK5QJ7RSE9P4",
  "eventType": "document-insights.extraction.completed",
  "occurredAt": "2026-04-07T10:22:00Z",
  "apiVersion": "v1",
  "data": {
    "extractionId": "69654f0bc073ef404baec649"
  }
}
```

The `data` field contains the event-specific payload. See the [Event Reference](/api/event-reference) for complete schemas and examples.

## Delivery headers

Every webhook delivery includes:

| Header                         | Description                                                     |
| ------------------------------ | --------------------------------------------------------------- |
| `X-Plextera-Delivery-Id`       | ID of this subscription delivery, stable across retry attempts. |
| `X-Plextera-Event-Id`          | ID of the event, stable across retry attempts.                  |
| `X-Plextera-Event-Type`        | Event type string.                                              |
| `X-Plextera-Event-Occurred-At` | ISO 8601 timestamp when the event occurred.                     |
| `X-Plextera-Api-Version`       | API version, for example `v1`.                                  |
| `X-Plextera-Signature`         | HMAC-SHA256 signature for payload verification.                 |

## Verifying signatures

Each delivery is signed using the server-generated `signingSecret` returned when the subscription is created.
This confirms the delivery came from Plextera and that the payload was not modified.

**Signature format:**

```text
X-Plextera-Signature: t=<unix timestamp>,v1=<hex hmac-sha256>
```

**Verification steps:**

1. Extract `t` and `v1` from the header.
2. Construct the signed payload: `<t>.<raw request body>`.
3. Compute HMAC-SHA256 using your `signingSecret`.
4. Compare the computed signature with `v1` using a constant-time comparison.
5. Optionally reject old timestamps for replay protection.

**`Python`**

```python title="Python"
import hashlib
import hmac
import time


def verify_signature(
    payload: bytes,
    header: str,
    secret: str,
    max_age_seconds: int = 300,
) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    timestamp = int(parts["t"])
    signature = parts["v1"]

    if abs(time.time() - timestamp) > max_age_seconds:
        return False

    signed = f"{timestamp}.".encode() + payload
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)
```

**`Node.js`**

```typescript title="Node.js"
import { createHmac, timingSafeEqual } from "crypto";

function verifySignature(
  payload: Buffer,
  header: string,
  secret: string,
  maxAgeSeconds = 300,
): boolean {
  const parts = Object.fromEntries(
    header.split(",").map((p) => p.split("=") as [string, string]),
  );
  const timestamp = parseInt(parts["t"], 10);
  const signature = parts["v1"];

  if (Math.abs(Date.now() / 1000 - timestamp) > maxAgeSeconds) {
    return false;
  }

  const signed = Buffer.concat([
    Buffer.from(timestamp + "."),
    payload,
  ]);
  const expected = createHmac("sha256", secret)
    .update(signed)
    .digest("hex");

  return timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}
```

> **Warning**
>
> Public list, get, and update responses never contain the signing secret, and the Public API has no reveal endpoint.
> Store the value from the successful create response securely.
> Authorized dashboard users can explicitly reveal the current secret if needed.

## Retry and idempotency

```mermaid
sequenceDiagram
    participant P as Plextera
    participant W as Your endpoint
    P->>W: POST event (signed)
    alt 2xx response
        W-->>P: 200 OK
        Note over P: Delivered
    else non-2xx or timeout
        W-->>P: error
        Note over P: Retry on the automatic schedule
    end
```

* The same `eventId` may be delivered more than once.
* Your webhook handler should be idempotent. Store processed `eventId` values or use your own deduplication key.
* If a document is reprocessed after a terminal state, a later terminal state can produce a new event.

### Automatic retry schedule

The first attempt is immediate.
A non-2xx response, connection error, or timeout then follows this schedule:

| Attempt | Delay after previous attempt | Approximate time from first attempt |
| ------: | ---------------------------: | ----------------------------------: |
|       1 |                    Immediate |                               `T+0` |
|       2 |                     1 minute |                              `T+1m` |
|       3 |                    2 minutes |                              `T+3m` |
|       4 |                    4 minutes |                              `T+7m` |
|       5 |                    8 minutes |                             `T+15m` |
|       6 |                   16 minutes |                             `T+31m` |

The maximum of six attempts includes the initial attempt.
After the sixth failed attempt, the delivery becomes `failed`.
The actual timestamps can be slightly later under load or during infrastructure recovery.

## Events and deliveries

Plextera keeps four related records:

* An **event** is one immutable product occurrence and contains the exact webhook payload.
* A **subscription** defines which events should be sent and to which endpoint.
* A **delivery** represents one event sent to one subscription.
* An **attempt** is one HTTP request made for a delivery.

An event is recorded even when no active subscription matches it.
One event can have several deliveries when several subscriptions match or when someone manually resends it.

**An event is one occurrence, not one document.**
If an extraction is processed, corrected, and processed again, each completion is its own event with its own `eventId` and its own payload, so you can receive `document-insights.extraction.completed` more than once for the same extraction - the second time carrying the corrected data.
The same holds for a workflow run that is retried.
Deduplicate by `eventId`, and use `data.completedAt` to tell one occurrence from another; retries and redeliveries of the same occurrence always reuse the same `eventId`.

`GET /events` lists the events your workspace could have been told about: an event appears when its type is one you subscribe to and it was recorded at or after that subscription existed.
Each event type has its own start, so subscribing to a new type does not add that type's earlier history.
Events a paused or mis-filtered subscription missed are kept, because those are the ones worth reading when webhooks stop arriving; event types you do not subscribe to, and events from before you subscribed, are not listed.
A single event stays retrievable by id with `GET /events/{eventId}` either way.

## Inspect events

Use `GET /events` to see events for the workspace, whether or not they were delivered.
Results are newest first by default.
Filter by `eventType`, `subscriptionId`, and the inclusive event-record `createdAt` range with `from` and `to`, or use `sort=asc` to read oldest first.
`from` narrows the range: a value earlier than your subscription does not return events from before it.
When `subscriptionId` is present, the list contains only events with a delivery to that subscription and each event's `deliverySummary` is scoped to that subscription.
Manual resends do not duplicate the event in this list.

```bash
curl "https://api.plextera.com/api/public/v1/events?eventType=document-insights.extraction.completed&from=2026-04-01T00:00:00Z&to=2026-04-30T23:59:59Z" \
  -H "Authorization: api-key YOUR_API_KEY"
```

Each item includes `deliverySummary` with counts for `pending`, `retrying`, `delivered`, and `failed`.
Use `GET /events/{eventId}` to load the exact event payload.
For Document Insights events, `document.contentUrl` is minted when the event is created and remains valid for up to five days.
Retries and manual resends use the same payload and URL.
If the URL has expired, call `GET /document-insights/extractions/{extractionId}` to obtain a fresh one.

To open event history for one subscription:

```bash
curl "https://api.plextera.com/api/public/v1/events?subscriptionId=sub_..." \
  -H "Authorization: api-key YOUR_API_KEY"
```

## Inspect deliveries for an event

Use `GET /events/{eventId}/deliveries` to see every delivery of one event across subscriptions and manual resends.
Results are newest first by default.
Filter by `status` (`pending`, `retrying`, `delivered`, `failed`), `subscriptionId`, and the inclusive `createdAt` range with `from` and `to`.

```bash
curl "https://api.plextera.com/api/public/v1/events/evt_.../deliveries?status=failed&sort=asc" \
  -H "Authorization: api-key YOUR_API_KEY"
```

Each delivery identifies its `subscriptionId` and `endpointUrl`.
It also shows its `status`, `attemptCount`, the last HTTP `responseStatusCode`, the last `error`, and `nextAttemptAt` for scheduled retries.
For a manual resend, `resendOfDeliveryId` identifies the delivery selected by the user.
This is the fastest way to debug a misbehaving webhook endpoint: if deliveries are `retrying` or `failed`, the response code and error tell you why.

To inspect history for one subscription, use `GET /events?subscriptionId={subscriptionId}` and open an event's deliveries.

### Inspect one delivery

Use the delivery `id` from the list to inspect the event payload and recent HTTP attempt history:

```bash
curl "https://api.plextera.com/api/public/v1/events/evt_.../deliveries/dlv_..." \
  -H "Authorization: api-key YOUR_API_KEY"
```

The response returns up to the 100 most recent attempt records, ordered oldest first within that returned window. `totalAttemptRecords` is the total stored history count and `attemptsTruncated: true` means earlier records were omitted. `attemptCount` counts completed attempts whose result was applied to the delivery, so it can be lower than `totalAttemptRecords` when an in-progress or superseded attempt is retained.

Each attempt includes:

* `trigger` - `automatic` or `manual`.
* `status` and start/completion timestamps.
* `durationMs`.
* the actual endpoint URL used for that attempt.
* safe request metadata. `X-Plextera-Signature` is shown as `[redacted]`; the secret and computed signature are not exposed in captured request headers. Treat the endpoint response body as opaque endpoint-controlled text.
* the endpoint's HTTP status and up to the first 16,000 characters of its response body.
* a structured `error` with a category, code, and message when delivery failed.

The `response` object is omitted when Plextera received no HTTP response, for example after a DNS, TLS, connection, timeout, or endpoint-configuration failure. The `error.category` distinguishes those cases.

> **Warning**
>
> Delivery details can contain the original event payload and response data from your endpoint. The API returns them with `Cache-Control: no-store`; avoid copying them into logs that are accessible more broadly than the source data.

### Resend an event manually

You can resend any delivery, including one that is already `delivered`, `pending`, or `retrying`:

```bash
curl -X POST "https://api.plextera.com/api/public/v1/events/evt_.../deliveries/dlv_.../resend" \
  -H "Authorization: api-key YOUR_API_KEY"
```

The endpoint returns `202 Accepted` with a new delivery in `pending` state.
The original delivery is unchanged.
A manual resend:

* requires the original subscription to still exist and be `active`;
* creates a new `deliveryId` and sets `resendOfDeliveryId` to the selected delivery;
* keeps the same `eventId` and exact event payload;
* uses the subscription's current endpoint and signing secret;
* makes one HTTP attempt and does not start an automatic retry sequence if that attempt fails;
* creates another resend on every successful request, including concurrent requests.

A request can reach your endpoint even if Plextera does not receive its response.
A resend can also intentionally deliver an event that your endpoint already processed.
Keep the webhook handler idempotent and deduplicate using `eventId`.

## Related reference

* [Event Reference](/api/event-reference) - webhook payload schemas and examples
* [Event Subscriptions API](/api/api-reference/event-subscriptions) - create and manage subscriptions, inspect events and deliveries, and resend deliveries