How to Set Up Webhooks to Receive Event Notifications from Composio

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, 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.

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:

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:

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 that demonstrates verification without a framework:

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 Complete verification API documentation, header specifications, and signature algorithm details.
ts/packages/core/src/types/webhookEvents.types.ts TypeScript definitions for IncomingTriggerPayload and connection-expired event schemas.
ts/examples/triggers/src/webhook-server.ts Production-ready Bun server demonstrating full verification flow.
fern/snippets/triggers/typescript/trigger-webhook.ts Minimal TypeScript snippet for Express/Node environments.
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 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.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →