# How to Set Up Webhooks to Receive Event Notifications from Composio

> Learn how to set up webhooks with Composio to receive event notifications. Configure an HTTP endpoint to verify signatures and normalize trigger events for your application.

- Repository: [Composio/composio](https://github.com/composiohq/composio)
- Tags: how-to-guide
- Published: 2026-02-19

---

**Configure an HTTP endpoint that uses the Composio SDK to verify HMAC-SHA256 signatures and normalize incoming trigger events into a typed `IncomingTriggerPayload` object.**

To set up webhooks to receive event notifications from Composio, you must expose a public URL that can receive raw POST requests from the Composio platform. The ComposioHQ/composio repository provides SDK methods that handle cryptographic verification and payload normalization across multiple webhook versions (V1, V2, and V3).

## Prerequisites for Webhook Reception

Before writing code, ensure your infrastructure meets these requirements:

1. **Public HTTPS endpoint**: Composio delivers events to a URL you control. Local development requires a tunnel such as ngrok.
2. **Raw body access**: Your framework must expose the unparsed request body as a string or buffer. Parsing JSON before verification will invalidate the signature check.
3. **Webhook secret**: Retrieve your project's signing secret from the Composio dashboard (Project → Settings). Store this in an environment variable such as `COMPOSIO_WEBHOOK_SECRET`.

## Understanding the Webhook Verification Flow

Composio signs every outgoing request using HMAC-SHA256. The verification logic, documented in [`ts/docs/advanced/webhook-verification.md`](https://github.com/ComposioHQ/composio/blob/main/ts/docs/advanced/webhook-verification.md), performs three security checks:

- **Signature validation**: Computes `HMAC-SHA256(${id}.${timestamp}.${payload}, secret)`, Base64-encodes the result, and compares it to the `v1,` prefix in the `webhook-signature` header.
- **Timestamp tolerance**: Rejects requests where the `webhook-timestamp` header is older than 5 minutes (configurable) to prevent replay attacks.
- **Version normalization**: Converts V1, V2, and V3 payload shapes into a consistent `IncomingTriggerPayload` type defined in [`ts/packages/core/src/types/webhookEvents.types.ts`](https://github.com/ComposioHQ/composio/blob/main/ts/packages/core/src/types/webhookEvents.types.ts).

### Required Headers

| Header | Purpose |
|--------|---------|
| `webhook-id` | Unique event identifier used in signature computation. |
| `webhook-timestamp` | Unix timestamp in seconds; validated against tolerance window. |
| `webhook-signature` | Comma-separated signatures (e.g., `v1,abc123...`). |
| `x-composio-webhook-version` | Payload version indicator (V1, V2, or V3). |

## Implementing the Webhook Endpoint

The `composio.triggers.verifyWebhook` method handles all cryptographic and normalization logic. Below are production-ready implementations for common frameworks.

### Express.js Implementation

Use `express.raw()` to ensure the body remains a string for the SDK. This example follows the pattern shown in [`fern/snippets/triggers/typescript/trigger-webhook.ts`](https://github.com/ComposioHQ/composio/blob/main/fern/snippets/triggers/typescript/trigger-webhook.ts):

```typescript
import express from 'express';
import { Composio, ComposioWebhookSignatureVerificationError } from '@composio/core';
import 'dotenv/config';

const app = express();
const composio = new Composio({ apiKey: process.env.COMPOSIO_API_KEY });

app.post(
  '/webhook',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    try {
      const result = await composio.triggers.verifyWebhook({
        payload: req.body.toString(),
        signature: req.headers['webhook-signature'] as string,
        id: req.headers['webhook-id'] as string,
        timestamp: req.headers['webhook-timestamp'] as string,
        secret: process.env.COMPOSIO_WEBHOOK_SECRET!,
      });

      console.log('Verified:', result.payload.triggerSlug);
      res.status(200).send('OK');
    } catch (e) {
      if (e instanceof ComposioWebhookSignatureVerificationError) {
        res.status(401).json({ error: 'Unauthorized' });
      } else {
        res.status(400).json({ error: 'Bad Request' });
      }
    }
  }
);

app.listen(3000);

```

### Next.js API Route

For the App Router, read the raw body using `request.text()` before passing it to the verification method:

```typescript
import { NextRequest, NextResponse } from 'next/server';
import { Composio, ComposioWebhookSignatureVerificationError } from '@composio/core';

const composio = new Composio({ apiKey: process.env.COMPOSIO_API_KEY });

export async function POST(request: NextRequest) {
  try {
    const payload = await request.text();
    const result = await composio.triggers.verifyWebhook({
      payload,
      signature: request.headers.get('webhook-signature')!,
      id: request.headers.get('webhook-id')!,
      timestamp: request.headers.get('webhook-timestamp')!,
      secret: process.env.COMPOSIO_WEBHOOK_SECRET!,
    });

    return NextResponse.json({ received: true });
  } catch (e) {
    if (e instanceof ComposioWebhookSignatureVerificationError) {
      return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
    }
    return NextResponse.json({ error: 'Bad Request' }, { status: 400 });
  }
}

```

### Bun Server Example

The repository includes a standalone example at [`ts/examples/triggers/src/webhook-server.ts`](https://github.com/ComposioHQ/composio/blob/main/ts/examples/triggers/src/webhook-server.ts) that demonstrates verification without a framework:

```typescript
import {
  Composio,
  ComposioWebhookSignatureVerificationError,
  ComposioWebhookPayloadError,
} from '@composio/core';

const SECRET = process.env.COMPOSIO_WEBHOOK_SECRET!;
const composio = new Composio({ apiKey: process.env.COMPOSIO_API_KEY });

Bun.serve({
  port: process.env.PORT || 3000,
  async fetch(request) {
    if (request.url.endsWith('/health')) return new Response('OK');
    if (!request.url.endsWith('/webhook') || request.method !== 'POST') {
      return new Response('Not Found', { status: 404 });
    }

    const id = request.headers.get('webhook-id') ?? '';
    const timestamp = request.headers.get('webhook-timestamp') ?? '';
    const signature = request.headers.get('webhook-signature') ?? '';
    const payload = await request.text();

    try {
      const result = await composio.triggers.verifyWebhook({
        id, timestamp, signature, payload, secret: SECRET,
      });
      console.log('Verified:', result.payload.triggerSlug);
      return Response.json({ success: true });
    } catch (err) {
      if (err instanceof ComposioWebhookSignatureVerificationError) {
        return Response.json({ error: 'Unauthorized' }, { status: 401 });
      }
      if (err instanceof ComposioWebhookPayloadError) {
        return Response.json({ error: 'Bad Request' }, { status: 400 });
      }
      return Response.json({ error: 'Internal Error' }, { status: 500 });
    }
  },
});

```

## Handling Errors and Edge Cases

The SDK throws specific error classes that map to HTTP status codes appropriate for webhook responses:

- **`ComposioWebhookSignatureVerificationError`**: Thrown when the HMAC signature does not match or the timestamp exceeds the tolerance window. Return **HTTP 401**.
- **`ComposioWebhookPayloadError`**: Thrown when headers are missing or the payload cannot be parsed. Return **HTTP 400**.
- **Generic errors**: Return **HTTP 500** for unexpected failures.

Always return a non-2xx status code for verification failures so Composio can retry delivery according to its backoff policy.

## Key Files and References

| File | Purpose |
|------|---------|
| [`ts/docs/advanced/webhook-verification.md`](https://github.com/ComposioHQ/composio/blob/main/ts/docs/advanced/webhook-verification.md) | Complete verification API documentation, header specifications, and signature algorithm details. |
| [`ts/packages/core/src/types/webhookEvents.types.ts`](https://github.com/ComposioHQ/composio/blob/main/ts/packages/core/src/types/webhookEvents.types.ts) | TypeScript definitions for `IncomingTriggerPayload` and connection-expired event schemas. |
| [`ts/examples/triggers/src/webhook-server.ts`](https://github.com/ComposioHQ/composio/blob/main/ts/examples/triggers/src/webhook-server.ts) | Production-ready Bun server demonstrating full verification flow. |
| [`fern/snippets/triggers/typescript/trigger-webhook.ts`](https://github.com/ComposioHQ/composio/blob/main/fern/snippets/triggers/typescript/trigger-webhook.ts) | Minimal TypeScript snippet for Express/Node environments. |
| [`fern/snippets/triggers/python/trigger-webhook.py`](https://github.com/ComposioHQ/composio/blob/main/fern/snippets/triggers/python/trigger-webhook.py) | Equivalent Python SDK implementation for Flask/FastAPI. |

## Summary

- **Expose a raw body endpoint**: Configure your framework to provide the unparsed request body as a string to ensure signature verification succeeds.
- **Use `composio.triggers.verifyWebhook`**: This method handles HMAC-SHA256 validation, timestamp tolerance checks, and normalizes V1/V2/V3 payloads into a consistent `IncomingTriggerPayload`.
- **Return proper HTTP codes**: Respond with 401 for signature failures, 400 for malformed payloads, and 200 only after successful verification.
- **Secure your secret**: Store `COMPOSIO_WEBHOOK_SECRET` in environment variables and never log the raw signature headers.

## Frequently Asked Questions

### What happens if I parse the JSON body before calling verifyWebhook?

Parsing the body into an object before verification will invalidate the signature check because the SDK expects the exact raw string that Composio signed. Always pass the raw request body (e.g., `req.body.toString()` in Express or `await request.text()` in Next.js) to `verifyWebhook`.

### How does Composio prevent replay attacks on webhook endpoints?

The SDK enforces a default timestamp tolerance of 5 minutes. The `webhook-timestamp` header is validated against the current server time, and requests outside this window are rejected with a `ComposioWebhookSignatureVerificationError`. You can configure this tolerance via the `tolerance` option in `verifyWebhook`.

### Can I use the Python SDK to verify webhooks instead of TypeScript?

Yes. The Python SDK provides equivalent functionality to `composio.triggers.verifyWebhook`. Reference the implementation in [`fern/snippets/triggers/python/trigger-webhook.py`](https://github.com/ComposioHQ/composio/blob/main/fern/snippets/triggers/python/trigger-webhook.py) for a Flask or FastAPI example that performs HMAC-SHA256 verification and returns the normalized payload.

### What is the difference between webhook versions V1, V2, and V3?

V3 is the current default and provides the most structured schema. V1 and V2 are legacy formats with different payload shapes. The SDK's `verifyWebhook` method automatically detects the version via the `x-composio-webhook-version` header and normalizes all three into a consistent `IncomingTriggerPayload` type defined in [`ts/packages/core/src/types/webhookEvents.types.ts`](https://github.com/ComposioHQ/composio/blob/main/ts/packages/core/src/types/webhookEvents.types.ts).