> This page is for version v2026-02-09 (default).
> For other versions, use one of these documentation indexes:
> - v2026-02-09 (default): https://docs.extend.ai/2026-02-09/llms.txt
> - v2025-04-21: https://docs.extend.ai/2025-04-21/llms.txt
> - v2024-12-23: https://docs.extend.ai/2024-12-23/llms.txt

> ## Documentation Index
> Fetch the complete documentation index at: https://docs.extend.ai/llms.txt
> Use this file to discover all available pages before exploring further.
>
> ## API version
> The current API version is `2026-02-09`, served at the site root (no version prefix in URLs).
> If this page URL contains `/2025-04-21/` or `/2024-12-23/`, you are reading an older API version.
> Prefer the current docs at https://docs.extend.ai/llms.txt unless the user explicitly needs that older version.
> Do not treat older-version pages as the source of truth for new integrations.

# Webhooks Migration

> Guide for migrating webhook endpoints to API version 2026-02-09

## What's Changing

Event types now use the new resource names:

| Old Event Type                             | New Event Type           |
| ------------------------------------------ | ------------------------ |
| `processor_run.processed` (type: EXTRACT)  | `extract_run.processed`  |
| `processor_run.failed` (type: EXTRACT)     | `extract_run.failed`     |
| `processor_run.processed` (type: CLASSIFY) | `classify_run.processed` |
| `processor_run.failed` (type: CLASSIFY)    | `classify_run.failed`    |
| `processor_run.processed` (type: SPLITTER) | `split_run.processed`    |
| `processor_run.failed` (type: SPLITTER)    | `split_run.failed`       |
| `parser_run.processed`                     | `parse_run.processed`    |
| `parser_run.failed`                        | `parse_run.failed`       |

Payload structures also change to match the new API schemas and are documented in the API Reference.

---

## Step-by-Step Migration

This migration pattern allows you to test the new webhook format while keeping your existing integration working, with the ability to rollback at any point.

### Step 1: Create a New Webhook Endpoint (Disabled)

Create a new webhook endpoint either in the Extend dashboard or via the API (`POST /webhook_endpoints` + `POST /webhook_subscriptions`):

| Setting         | Value                                                                                                |
| --------------- | ---------------------------------------------------------------------------------------------------- |
| **URL**         | Same URL with a version query parameter, e.g., `https://example.com/webhooks?api_version=2026-02-09` |
| **API Version** | `2026-02-09`                                                                                         |
| **Events**      | Same events, using new names (e.g., `extract_run.processed` instead of `processor_run.processed`)    |
| **Status**      | **Disabled** (don't enable yet)                                                                      |

> **Note**
>
> The query parameter lets your code distinguish between events from the old and new endpoints.

**State after Step 1:**

```
Old endpoint (2025-04-21): ✅ Enabled, processing events
New endpoint (2026-02-09): ❌ Disabled
```

### Step 2: Deploy Code to Ignore New Events

Update your webhook handler to acknowledge but ignore events from the new endpoint:

#### TypeScript

```typescript
async function handleWebhook(req: Request) {
  const event = req.body;
  const apiVersion = req.query.api_version;

  // Ignore new version events (for now)
  if (apiVersion === "2026-02-09") {
    console.log("New webhook format received (ignoring):", event.eventType);
    return 200; // Acknowledge but don't process
  }

  // Process old version normally
  if (await hasProcessedEvent(event.id)) {
    return 200; // Idempotent
  }

  await processOldWebhookEvent(event);
  await markEventProcessed(event.id);
  return 200;
}
```

#### Python

```python
def handle_webhook(request):
    event = request.json
    api_version = request.args.get("api_version")

    # Ignore new version events (for now)
    if api_version == "2026-02-09":
        print(f"New webhook format received (ignoring): {event.get('eventType')}")
        return "", 200  # Acknowledge but don't process

    # Process old version normally
    if has_processed_event(event["id"]):
        return "", 200  # Idempotent

    process_old_webhook_event(event)
    mark_event_processed(event["id"])
    return "", 200
```

#### Java

```java
@PostMapping("/webhook")
public ResponseEntity<String> handleWebhook(
    @RequestBody Map<String, Object> event,
    @RequestParam("api_version") String apiVersion
) {
    // Ignore new version events (for now)
    if ("2026-02-09".equals(apiVersion)) {
        System.out.println("New webhook format received (ignoring): " + event.get("eventType"));
        return ResponseEntity.ok(""); // Acknowledge but don't process
    }

    // Process old version normally
    String eventId = (String) event.get("id");
    if (hasProcessedEvent(eventId)) {
        return ResponseEntity.ok(""); // Idempotent
    }

    processOldWebhookEvent(event);
    markEventProcessed(eventId);
    return ResponseEntity.ok("");
}
```

**Then enable the new webhook endpoint** (in the dashboard or via `POST /webhook_endpoints/{id}`).

**State after Step 2:**

```
Old endpoint (2025-04-21): ✅ Enabled, processing events
New endpoint (2026-02-09): ✅ Enabled, acknowledged but ignored

Both endpoints receive events, only old is processed.
```

This lets you verify:

* New endpoint is receiving events
* Payload format matches documentation
* No errors in basic parsing

### Step 3: Deploy Code to Process New Events

Update your handler to process new events and reject old ones:

#### TypeScript

```typescript
async function handleWebhook(req: Request) {
  const event = req.body;
  const apiVersion = req.query.api_version;

  // Reject old version (triggers retry for rollback safety)
  if (apiVersion === "2025-04-21") {
    console.log("Old webhook format (rejecting):", event.eventType);
    return 400; // Reject so Extend retries if we need to rollback
  }

  // Process new version
  if (await hasProcessedEvent(event.id)) {
    return 200; // Idempotent
  }

  await processNewWebhookEvent(event);
  await markEventProcessed(event.id);
  return 200;
}
```

#### Python

```python
def handle_webhook(request):
    event = request.json
    api_version = request.args.get("api_version")

    # Reject old version (triggers retry for rollback safety)
    if api_version == "2025-04-21":
        print(f"Old webhook format (rejecting): {event.get('eventType')}")
        return "", 400  # Reject so Extend retries if we need to rollback

    # Process new version
    if has_processed_event(event["id"]):
        return "", 200  # Idempotent

    process_new_webhook_event(event)
    mark_event_processed(event["id"])
    return "", 200
```

#### Java

```java
@PostMapping("/webhook")
public ResponseEntity<String> handleWebhook(
    @RequestBody Map<String, Object> event,
    @RequestParam("api_version") String apiVersion
) {
    // Reject old version (triggers retry for rollback safety)
    if ("2025-04-21".equals(apiVersion)) {
        System.out.println("Old webhook format (rejecting): " + event.get("eventType"));
        return ResponseEntity.badRequest().body(""); // Reject so Extend retries
    }

    // Process new version
    String eventId = (String) event.get("id");
    if (hasProcessedEvent(eventId)) {
        return ResponseEntity.ok(""); // Idempotent
    }

    processNewWebhookEvent(event);
    markEventProcessed(eventId);
    return ResponseEntity.ok("");
}
```

> **Warning**
>
> **Return `400` for old events.** This causes Extend to retry delivery. If you need to rollback, those events will be re-delivered when you revert your code.

**State after Step 3:**

```
Old endpoint (2025-04-21): ✅ Enabled, rejected (returns 400, retries queued)
New endpoint (2026-02-09): ✅ Enabled, processing events

Both endpoints receive events, only new is processed.
```

### Step 4: Monitor and Validate

Monitor your new webhook processing for:

* Processing errors
* Unexpected payload formats
* Business logic correctness

**If issues arise:**

1. Revert to Step 2 code (ignore new, process old)
2. Temporarily disable the new webhook endpoint
3. Old endpoint will process the retried events (because you returned 400)
4. Investigate and fix issues
5. Re-enable new endpoint and resume from Step 3

### Step 5: Disable the Old Webhook Endpoint

Once you're confident in the new endpoint:

1. **Disable** the old webhook endpoint (in the dashboard or via `POST /webhook_endpoints/{id}`)
2. Remove the version check from your code
3. Clean up old event processing logic

**State after Step 5:**

```
Old endpoint (2025-04-21): ❌ Disabled
New endpoint (2026-02-09): ✅ Enabled, processing events

Migration complete!
```

---

## SDK Webhook Helpers

The SDK includes utilities for verifying signatures and parsing events with type-safe payloads:

#### TypeScript

```typescript
import { ExtendClient, WebhookSignatureVerificationError } from "extend-ai";

const client = new ExtendClient();

app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
  try {
    const event = client.webhooks.verifyAndParse(
      req.body.toString(),
      req.headers,
      process.env.EXTEND_WEBHOOK_SECRET!
    );

    switch (event.eventType) {
      case "extract_run.processed":
        console.log("Output:", event.payload.output);
        break;
      case "workflow_run.completed":
        console.log("Workflow done:", event.payload.id);
        break;
    }

    res.status(200).send("OK");
  } catch (err) {
    if (err instanceof WebhookSignatureVerificationError) {
      res.status(401).send("Invalid signature");
    }
  }
});
```

#### Python

```python
from extend_ai import Extend, WebhookSignatureVerificationError
import os

client = Extend()

@app.route("/webhook", methods=["POST"])
def webhook():
    try:
        event = client.webhooks.verify_and_parse(
            request.data.decode(),
            dict(request.headers),
            os.environ["EXTEND_WEBHOOK_SECRET"]
        )

        if event.event_type == "extract_run.processed":
            print("Output:", event.payload.output)
        elif event.event_type == "workflow_run.completed":
            print("Workflow done:", event.payload.id)

        return "OK", 200
    except WebhookSignatureVerificationError:
        return "Invalid signature", 401
```

#### Java

```java
var client = ExtendClient.builder().build();

@PostMapping("/webhook")
public ResponseEntity<String> webhook(
    @RequestBody String body,
    @RequestHeader Map<String, String> headers
) {
    try {
        var event = client.webhooks().verifyAndParse(
            body, headers, System.getenv("EXTEND_WEBHOOK_SECRET")
        );

        switch (event.getEventType()) {
            case "extract_run.processed":
                System.out.println("Output: " + event.getPayload().getOutput());
                break;
            case "workflow_run.completed":
                System.out.println("Workflow done: " + event.getPayload().getId());
                break;
        }
        return ResponseEntity.ok("OK");
    } catch (WebhookSignatureVerificationException e) {
        return ResponseEntity.status(401).body("Invalid signature");
    }
}
```

### Available Methods

| Method                 | Description                                  |
| ---------------------- | -------------------------------------------- |
| `verifyAndParse()`     | Verify signature and parse event in one call |
| `verify()`             | Verify signature only (returns boolean)      |
| `parse()`              | Parse event without verification             |
| `isSignedUrlEvent()`   | Check for signed URL payloads                |
| `fetchSignedPayload()` | Fetch full payload from signed URL           |

---

## Handling Signed URL Payloads

For large payloads (e.g., workflow runs with many documents), Extend delivers webhooks with a signed URL instead of the full payload. This keeps webhook delivery fast and reliable.

By default, `verifyAndParse()` throws an error if a signed URL payload is received. To handle these, opt-in with `allowSignedUrl`:

#### TypeScript

```typescript
const event = client.webhooks.verifyAndParse(body, headers, secret, {
  allowSignedUrl: true
});

if (client.webhooks.isSignedUrlEvent(event)) {
  console.log("Resource ID:", event.payload.id);
  const fullEvent = await client.webhooks.fetchSignedPayload(event);
  console.log("Full payload:", fullEvent.payload);
}
```

#### Python

```python
event = client.webhooks.verify_and_parse(body, headers, secret,
    allow_signed_url=True
)

if client.webhooks.is_signed_url_event(event):
    print("Resource ID:", event.payload.id)
    full_event = await client.webhooks.fetch_signed_payload(event)
    print("Full payload:", full_event.payload)
```

#### Java

```java
var raw = client.webhooks().verifyAndParseWithOptions(body, headers, secret,
    VerifyAndParseOptions.builder().allowSignedUrl(true).build()
);

if (raw.isSignedUrlEvent()) {
    System.out.println("Resource ID: " + raw.getSignedUrlEvent().getPayload().getId());
    var fullEvent = client.webhooks().fetchSignedPayload(raw.getSignedUrlEvent());
    System.out.println("Full payload: " + fullEvent);
}
```

> **Note**
>
> Signed URLs expire after 1 hour. Fetch the payload promptly.

---

## All Event Types

Use the `eventType` field for type-safe payload access. See the [Webhook Events reference](/webhooks/events) for the complete catalog and payload schemas.

```typescript
switch (event.eventType) {
  // Workflow run events
  case "workflow_run.completed":
  case "workflow_run.failed":
  case "workflow_run.needs_review":
  case "workflow_run.rejected":
  case "workflow_run.cancelled":
    // event.payload is WorkflowRun
    break;
  case "workflow_run.step_run.processed":
    // event.payload is a single StepRun (incremental step results)
    break;

  // Workflow lifecycle events
  case "workflow.created":
  case "workflow.deployed":
  case "workflow.deleted":
    // event.payload is Workflow
    break;

  // Extract run events
  case "extract_run.processed":
  case "extract_run.failed":
    // event.payload is ExtractRun
    break;

  // Extractor lifecycle events
  case "extractor.created":
  case "extractor.updated":
  case "extractor.deleted":
  case "extractor.draft.updated":
  case "extractor.version.published":
    // event.payload is Extractor / ExtractorVersion
    break;

  // Classify run events
  case "classify_run.processed":
  case "classify_run.failed":
    // event.payload is ClassifyRun
    break;

  // Classifier lifecycle events
  case "classifier.created":
  case "classifier.updated":
  case "classifier.deleted":
  case "classifier.draft.updated":
  case "classifier.version.published":
    // event.payload is Classifier / ClassifierVersion
    break;

  // Split run events
  case "split_run.processed":
  case "split_run.failed":
    // event.payload is SplitRun
    break;

  // Splitter lifecycle events
  case "splitter.created":
  case "splitter.updated":
  case "splitter.deleted":
  case "splitter.draft.updated":
  case "splitter.version.published":
    // event.payload is Splitter / SplitterVersion
    break;

  // Parse events
  case "parse_run.processed":
  case "parse_run.failed":
    // event.payload is ParseRun
    break;

  // Edit events
  case "edit_run.processed":
  case "edit_run.failed":
    // event.payload is EditRun
    break;

  // Batch events
  case "batch_processor_run.processed":
  case "batch_processor_run.failed":
    // event.payload is BatchProcessorRun (extract/classify/split batches)
    break;
  case "batch_parse_run.processed":
  case "batch_parse_run.failed":
    // event.payload is BatchParseRun
    break;
}
```

---

## Security Best Practices

1. **Always verify signatures** — Never skip verification in production
2. **Use environment variables** — Store your webhook secret securely
3. **Use raw body** — Parse the body as a string before verification
4. **Implement idempotency** — Use `eventId` to handle duplicate deliveries
5. **Respond quickly** — Return 200 before heavy processing

#### TypeScript

```typescript
app.post("/webhook", async (req, res) => {
  const event = client.webhooks.verifyAndParse(body, headers, secret);

  // Acknowledge quickly
  res.status(200).send("OK");

  // Process asynchronously
  processWebhookAsync(event).catch(console.error);
});
```

#### Python

```python
@app.route("/webhook", methods=["POST"])
def webhook():
    event = client.webhooks.verify_and_parse(body, headers, secret)

    # Acknowledge quickly, process asynchronously
    background_tasks.add_task(process_webhook_async, event)
    return "", 200
```

#### Java

```java
@PostMapping("/webhook")
public ResponseEntity<String> webhook(@RequestBody String body, @RequestHeader Map<String, String> headers) {
    var event = client.webhooks().verifyAndParse(body, headers, secret);

    // Acknowledge quickly, process asynchronously
    CompletableFuture.runAsync(() -> processWebhookAsync(event));
    return ResponseEntity.ok("OK");
}
```

---

## Need Help?

If you encounter any issues while migrating your webhook handlers, please contact our support team at [support@extend.app](mailto:support@extend.app).

---

## Migration Guides

| Guide                            | Migrating From           | Migrating To                            |
| -------------------------------- | ------------------------ | --------------------------------------- |
| [Overview](./overview)           | —                        | What's new and how to upgrade           |
| [Extract Runs](./extract)        | `/processor_runs`        | `/extract_runs` + `/extract`            |
| [Classify Runs](./classify)      | `/processor_runs`        | `/classify_runs` + `/classify`          |
| [Split Runs](./split)            | `/processor_runs`        | `/split_runs` + `/split`                |
| [Parse Runs](./parse)            | `/parse`, `/parse/async` | `/parse_runs` + `/parse`                |
| [Edit Runs](./edit)              | `/edit`, `/edit/async`   | `/edit_runs` + `/edit`                  |
| [Extractors](./extractors)       | `/processors`            | `/extractors`                           |
| [Classifiers](./classifiers)     | `/processors`            | `/classifiers`                          |
| [Splitters](./splitters)         | `/processors`            | `/splitters`                            |
| [Files](./files)                 | `/files`                 | `/files` (breaking changes)             |
| [Evaluation Sets](./evaluation)  | evaluation endpoints     | Updated evaluation endpoints            |
| [Workflow Runs](./workflow-runs) | `/workflow_runs`         | `/workflow_runs` (breaking changes)     |
| [Webhooks](./webhooks)           | `processor_run.*` events | `extract_run.*`, `classify_run.*`, etc. |