> This page is for version v2025-04-21.
> 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.

# Webhook Configuration

## Setting up a webhook

To set up a webhook, you can create one in the "Developers" tab on the sidebar, under the "webhook endpoints" section.

![Webhooks settings page with the create endpoint button](/_fern-files/_img/c2fe20b4d5b5d4c40548807278d3efb9fb22ec0501e2c20acf1e9cd1d769619f.webp)

### Global webhooks

To receive events for webhooks you need to subscribe to events. When creating a webhook endpoint, you can subscribe to the `GLOBAL` event types.
These events are not associated with a specific workflow or processor and are global to your workspace.

![Modal for creating a new webhook endpoint](/_fern-files/_img/1472f24bfc43b0cfb9d5ed5a4c90aebd5a1d88e73859afd11a0acf6387579602.webp)

See the [Events](/webhooks/events) section for more details on the different event types you can subscribe to.

### Webhook delivery format

We support two delivery formats for webhooks:

* **JSON**: This is the default and most common format. It is a simple JSON object with the event data in the body.
* **Signed Download URL**: This is a signed URL that allows you to download the event data as a file. This is useful for scenarios where the webhook event payload is too large to fit in the body of the request, or you have payload size constraints on your webhook endpoint.

We also provide the option to send all payloads as a signed download URL over a certain size threshold, which can be configured in bytes when creating/updating a webhook endpoint.

The signed download URL has a one hour expiration time. When manually re-trying a webhook request, a new URL will be generated each time, and when viewing the webhook history for events sent as a URL, a new URL is generated for viewing.

To configure the delivery format, you can select the desired format in the "Advanced options" dropdown when creating/updating a webhook endpoint.

![Webhook settings for including file download URLs in payloads](/_fern-files/_img/7ea69d8778b47bd43bff646bd131f22672c429767b64aaa7d6d0afd88c23f163.webp)

### Workflow specific subscriptions

After you have created one or more webhook endpoints from the "Developer" tab, you can subscribe to events for specific workflows by navigating to the desired workflow(s) and opening the "Webhook Subscriptions" menu.

![Selecting webhook settings in the dashboard](/_fern-files/_img/bb0ebb4412cc3153e58b97b7cc9bd6db470c6ffc6087e7d8e8544a8e348fe60c.webp)

Then you can create new subscriptions for the desired endpoints and event types.

![Create subscription button on the webhook endpoint page](/_fern-files/_img/b29fee9c87d4ae3fd46bd62082d46e83794f536acacd2c742d4ef3cd80517c28.webp)

![Modal for creating a webhook subscription](/_fern-files/_img/18d07c7f71fc0dcb8bd76101db0eb38032d4805a977a6836e66b9025eca728a9.webp)

Once subscribed, you will start to receive events to the specified webhook endpoint based on the selected event types each time you run a file(s) through that workflow.

See the [Events](/webhooks/events) section for more details on the different event types you can subscribe to and the shape of the payload for each event type.

### Processor run specific subscriptions

You can also subscribe to events for specific processor runs. This is necessary if you are running processors directly and not via a workflow.

To start, navigate to the "Overview" tab of the given processor in Studio, click "Webhook Subscriptions", and then click the "Add webhook subscription" button.

![Adding a webhook subscription from a processor page](/_fern-files/_img/75b03299e995ec22c44846bf19d5c747c9db0ee0196e6f06da8aaa380a74c14a.webp)

Next, you can select the desired event types you would like to subscribe to and assign the webhook endpoint you would like to receive events at.

![Modal for subscribing a processor to webhook events](/_fern-files/_img/07db1be7856fdd921ee10f3a41c6e36b899b8188800d5ba049cd19bb08727cfb.webp)

## Verifying webhook requests from Extend

Extend will sign each webhook request using a secret unique to the webhook. You can use this signature along with the timestamp to verify that the request is coming from Extend as well as protect against replay attacks.

### Using the SDK (Recommended)

The easiest way to verify webhook requests is using the SDK's built-in helper methods. These handle signature verification, timestamp validation, and event parsing automatically.

**`Python SDK`**

```python Python SDK
from extend_ai import Extend
from extend_ai.wrapper.errors import WebhookSignatureVerificationError

client = Extend(token="YOUR_API_KEY")

@app.post("/webhook")
def handle_webhook(request):
    try:
        # verify_and_parse validates the signature and parses the event in one step
        event = client.webhooks.verify_and_parse(
            body=request.body.decode(),      # Raw request body as string
            headers=dict(request.headers),   # Request headers as dict
            signing_secret="wss_your_signing_secret"  # Your webhook signing secret
        )

        # Handle the event based on type
        if event["eventType"] == "workflow_run.completed":
            print("Workflow completed:", event["payload"])
        elif event["eventType"] == "extract_run.processed":
            print("Extract run processed:", event["payload"])
        # ... handle other event types

        return {"status": "ok"}
    except WebhookSignatureVerificationError:
        return {"error": "Invalid signature"}, 401
```

**`TypeScript SDK`**

```typescript TypeScript SDK
import { ExtendClient } from "extend-ai";

const client = new ExtendClient({ token: "YOUR_API_KEY" });

app.post("/webhook", (req, res) => {
  try {
    // verifyAndParse validates the signature and parses the event in one step
    const event = client.webhooks.verifyAndParse(
      req.body.toString(),       // Raw request body as string
      req.headers,               // Request headers
      "wss_your_signing_secret"  // Your webhook signing secret
    );

    // Handle the event based on type
    switch (event.eventType) {
      case "workflow_run.completed":
        console.log("Workflow completed:", event.payload);
        break;
      case "extract_run.processed":
        console.log("Extract run processed:", event.payload);
        break;
      // ... handle other event types
    }

    res.status(200).send("OK");
  } catch (err) {
    if (err.name === "WebhookSignatureVerificationError") {
      res.status(401).send("Invalid signature");
    } else {
      res.status(500).send("Internal error");
    }
  }
});
```

**`Java SDK`**

```java Java SDK
import ai.extend.ExtendClient;
import ai.extend.types.WebhookEvent;
import ai.extend.wrapper.errors.WebhookSignatureVerificationError;
import java.util.Map;

ExtendClient client = ExtendClient.builder().apiKey("YOUR_API_KEY").build();

// Inside your webhook handler:
try {
    // verifyAndParse validates the signature and parses the event in one step
    WebhookEvent event = client.webhooks().verifyAndParse(
        requestBody,               // Raw request body as a string
        requestHeaders,            // Map<String, String> of request headers
        "wss_your_signing_secret"  // Your webhook signing secret
    );

    // event is a typed union — dispatch on the specific event type with
    // event.visit(new WebhookEvent.Visitor<>() { ... }) to handle
    // workflowRunCompleted, extractRunProcessed, and the other event types.
    handleEvent(event);

    // Respond 200 OK
} catch (WebhookSignatureVerificationError e) {
    // Respond 401 — invalid signature
}
```

> **Note**
>
> The Go SDK does not include a webhook verification helper. Go users should verify the signature manually — see [Manual Verification](#manual-verification) below.

The signing secret can be found in the webhooks table under the "Developer" tab:

![Webhook signing secret shown in endpoint settings](/_fern-files/_img/064501a29139db042683d8850104121ce062f531cd63c42af6d94558abc2bfad.webp)

### Handling Signed URL Payloads

For large payloads, webhooks may be delivered via a signed URL instead of inline. To handle these:

**`Python SDK`**

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

if client.webhooks.is_signed_url_event(event):
    # Fetch the full payload from the signed URL
    full_event = await client.webhooks.fetch_signed_payload(event)
    print("Full payload:", full_event["payload"])
else:
    # Normal inline payload
    print("Payload:", event["payload"])
```

**`TypeScript SDK`**

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

if (client.webhooks.isSignedUrlEvent(event)) {
  // Fetch the full payload from the signed URL
  const fullEvent = await client.webhooks.fetchSignedPayload(event);
  console.log("Full payload:", fullEvent.payload);
} else {
  // Normal inline payload
  console.log("Payload:", event.payload);
}
```

**`Java SDK`**

```java Java SDK
import ai.extend.wrapper.webhooks.RawWebhookEvent;
import ai.extend.wrapper.webhooks.VerifyAndParseOptions;
import ai.extend.types.WebhookEvent;

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

if (raw.isSignedUrlEvent()) {
    // Fetch the full payload from the signed URL
    WebhookEvent fullEvent = client.webhooks().fetchSignedPayload(raw.getSignedUrlEvent());
    System.out.println("Full payload: " + fullEvent);
} else {
    // Normal inline payload
    WebhookEvent event = raw.getEvent();
    System.out.println("Payload: " + event);
}
```

### Manual Verification

If you're not using the SDK, you can verify webhook signatures manually:

1. Retrieve the timestamp of the request from `x-extend-request-timestamp`, the body of the request, and the signing secret associated with the webhook.
2. Concatenate the timestamp and request body using the following format:
   `v0:${timestamp}:${requestBody}`
3. Compute a HMAC 256 digest on the resulting string using the signing secret as the key.
4. Compare this digest with the signature provided in `x-extend-request-signature`. If they are equal, then the request is verified to be from Extend.

**`Python`**

```python Python
import hmac
import hashlib
import time

timestamp = request.headers.get('x-extend-request-timestamp')
received_signature = request.headers.get('x-extend-request-signature')
signing_secret = "wss_your_signing_secret"

# Validate timestamp to prevent replay attacks
current_time = int(time.time())
if current_time - int(timestamp) > 300:  # 5 minutes
    raise ValueError('Request timestamp too old')

request_body_string = request.body.decode()

message = f"v0:{timestamp}:{request_body_string}"
expected_signature = hmac.new(
    signing_secret.encode('utf-8'),
    message.encode('utf-8'),
    hashlib.sha256
).hexdigest()

if not hmac.compare_digest(expected_signature, received_signature):
    raise ValueError('Invalid webhook signature')

# Proceed with webhook processing
```

**`TypeScript`**

```typescript TypeScript
import crypto from "crypto";

const timestamp = request.headers['x-extend-request-timestamp'];
const receivedSignature = request.headers['x-extend-request-signature'];
const signingSecret = "wss_your_signing_secret";

// Validate timestamp to prevent replay attacks
const currentTime = Math.floor(Date.now() / 1000);
if (currentTime - parseInt(timestamp) > 300) { // 5 minutes
  throw new Error('Request timestamp too old');
}

// Use the EXACT raw request body that Extend signed — not a re-serialized copy.
// Re-serializing (e.g. JSON.stringify(request.body)) can reorder keys or change
// whitespace and cause valid signatures to fail. In Express, capture the raw body
// on the webhook route with `express.raw({ type: '*/*' })`.
const requestBodyString = request.body.toString();

const message = `v0:${timestamp}:${requestBodyString}`;
const expectedSignature = crypto
  .createHmac("sha256", signingSecret)
  .update(message)
  .digest("hex");

// Constant-time comparison to avoid leaking timing information
const expectedBuffer = Buffer.from(expectedSignature);
const receivedBuffer = Buffer.from(receivedSignature ?? "");
if (
  expectedBuffer.length !== receivedBuffer.length ||
  !crypto.timingSafeEqual(expectedBuffer, receivedBuffer)
) {
  throw new Error('Invalid webhook signature');
}

// Proceed with webhook processing
```

**`Go`**

```go Go
import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"strconv"
	"time"
)

timestamp := headers.Get("x-extend-request-timestamp")
receivedSignature := headers.Get("x-extend-request-signature")
signingSecret := "wss_your_signing_secret"

// Validate timestamp to prevent replay attacks
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil || time.Now().Unix()-ts > 300 { // 5 minutes
	return fmt.Errorf("request timestamp too old or invalid")
}

// Use the exact raw request body that Extend signed — not a re-serialized copy
message := fmt.Sprintf("v0:%s:%s", timestamp, string(requestBody))
mac := hmac.New(sha256.New, []byte(signingSecret))
mac.Write([]byte(message))
expectedSignature := hex.EncodeToString(mac.Sum(nil))

// Constant-time comparison to avoid leaking timing information
if !hmac.Equal([]byte(expectedSignature), []byte(receivedSignature)) {
	return fmt.Errorf("invalid webhook signature")
}

// Proceed with webhook processing
```

**`Java`**

```java Java
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.HexFormat;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

String timestamp = request.getHeader("x-extend-request-timestamp");
String receivedSignature = request.getHeader("x-extend-request-signature");
String signingSecret = "wss_your_signing_secret";

// Validate timestamp to prevent replay attacks
if (Instant.now().getEpochSecond() - Long.parseLong(timestamp) > 300) { // 5 minutes
    throw new IllegalArgumentException("Request timestamp too old");
}

// Use the exact raw request body that Extend signed — not a re-serialized copy
String message = "v0:" + timestamp + ":" + requestBody;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(signingSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
String expectedSignature = HexFormat.of().formatHex(mac.doFinal(message.getBytes(StandardCharsets.UTF_8)));

// Constant-time comparison to avoid leaking timing information
if (receivedSignature == null || !MessageDigest.isEqual(
        expectedSignature.getBytes(StandardCharsets.UTF_8),
        receivedSignature.getBytes(StandardCharsets.UTF_8))) {
    throw new IllegalArgumentException("Invalid webhook signature");
}

// Proceed with webhook processing
```