Receiving events

Once a trigger is active, its events come to you as the same payload, whether you're testing locally or running in production. Develop against your local handler first, then point Composio at your production URL when you ship.

Receive events locally

While developing, you want trigger events on your machine. The best option forwards them to the real webhook handler you'll run in production, so you test the exact path (including parse() and signature verification) before you ship.

Quick look with subscribe()

subscribe() streams events straight to your process over a WebSocket, with no webhook URL, no tunnel, and no signing. It's the fastest way to eyeball what a trigger sends, but it bypasses your real webhook handler. Use it only for basic prototyping; for anything you intend to ship, forward events to your handler with one of the options below.

from composio import Composio

composio = Composio()

subscription = composio.triggers.subscribe()

@subscription.handle(trigger_id="your_trigger_id")
def handle_event(data):
    print(f"Event received: {data}")

subscription.wait_forever()
import { Composio } from '@composio/core';

const composio = new Composio();

await composio.triggers.subscribe(
  data => {
    console.log('Event received:', data);
  },
  { triggerId: 'your_trigger_id' }
);

Filter the stream by triggerId, triggerSlug, connectedAccountId, toolkits, or userId, or pass no filters to receive every trigger event in the project.

The Composio CLI streams realtime events and forwards each one to your local URL, signed exactly like production. No public URL, no tunnel, and it runs your real handler (and parse()) end to end.

composio dev triggers listen --forward "http://localhost:8000/webhooks/composio"

Events are signed with COMPOSIO_WEBHOOK_SECRET if it's set, otherwise the CLI prints a generated secret to verify against. Filter the stream with --toolkits, --trigger-slug, or --trigger-id, and tee events to a file with --out events.jsonl.

Cloudflare Tunnel

Expose your local server with Cloudflare Tunnel, no account needed for quick runs:

cloudflared tunnel --url http://localhost:8000

Register the printed trycloudflare.com URL as your webhook URL, then events reach your handler at http://localhost:8000/webhooks/composio.

ngrok

Expose your local server with ngrok:

ngrok http 8000

Register the printed ngrok-free.app URL as your webhook URL the same way.

Receive events in production

Register your webhook URL once per project. Composio then POSTs every trigger event to it. Set it from the SDK:

from composio import Composio

composio = Composio()

subscription = composio.triggers.set_webhook_subscription(
    webhook_url="https://your-app.com/webhooks/composio",
)
print(f"Delivering events to {subscription['webhook_url']}")
# Store subscription['secret'] to verify signatures
import { Composio } from '@composio/core';

const composio = new Composio();

const subscription = await composio.triggers.setWebhookSubscription({
  webhookUrl: 'https://your-app.com/webhooks/composio',
});
console.log(`Delivering events to ${subscription.webhookUrl}`);
// Store subscription.secret to verify signatures

Your webhook endpoint must be publicly reachable. Composio's outbound IPs are dynamic, so IP allowlists and VPN-only endpoints won't work. Authenticate payloads with signature verification instead.

Handling events

In your handler, pass the incoming request to parse(). It returns the typed, normalized payload. Pass verifySecret and it verifies the signature first, so one call both authenticates and parses.

import os
from composio import Composio

composio = Composio()

@app.post("/webhooks/composio")
async def webhook_handler(request: Request):
    # On async frameworks (FastAPI) read the raw body and pass body=/headers=.
    # On sync frameworks (Flask, Django) you can pass the request directly.
    # Use the raw body so the signature verifies. Omit verify_secret to skip it.
    result = composio.triggers.parse(
        body=await request.body(),
        headers=request.headers,
        verify_secret=os.environ["COMPOSIO_WEBHOOK_SECRET"],
    )

    if result["raw_payload"]["type"] == "composio.trigger.message":
        event = result["payload"]
        if event["trigger_slug"] == "GITHUB_COMMIT_EVENT":
            data = event["payload"]
            print(f"New commit by {data['author']}: {data['message']}")

    return {"status": "ok"}
import { Composio } from '@composio/core';

const composio = new Composio();

// Next.js App Router, Hono, or any Fetch-style handler
export async function POST(request: Request) {
  // parse() takes a Fetch Request (shown) or an Express-style { body, headers }.
  // Pass the raw body so the signature verifies; omit verifySecret to skip it.
  const result = await composio.triggers.parse(request, {
    verifySecret: process.env.COMPOSIO_WEBHOOK_SECRET,
  });

  if (result.rawPayload.type === 'composio.trigger.message') {
    const event = result.payload;
    if (event.triggerSlug === 'GITHUB_COMMIT_EVENT') {
      const data = event.payload;
      console.log(`New commit by ${data.author}: ${data.message}`);
    }
  }

  return Response.json({ status: 'ok' });
}

Composio delivers other project events (like connection expiry) to this same URL. parse() returns those too. Route on result.payload.triggerSlug and ignore what you don't handle.

Inspecting trigger payload schemas

Each trigger type declares the shape of the data it sends. Inspect it before you write your handler:

from composio import Composio

composio = Composio()

trigger_type = composio.triggers.get_type("GITHUB_COMMIT_EVENT")
print(trigger_type.payload)
# {"properties": {"author": {...}, "id": {...}, "message": {...}, "timestamp": {...}, "url": {...}}}
import { Composio } from '@composio/core';

const composio = new Composio();

const triggerType = await composio.triggers.getType('GITHUB_COMMIT_EVENT');
console.log(triggerType.payload);
// {"properties": {"author": {...}, "id": {...}, "message": {...}, "timestamp": {...}, "url": {...}}}

From the CLI, inspect a single trigger type's config and payload schema:

composio triggers info "GITHUB_COMMIT_EVENT"

Or generate typed stubs for your project (scope to the toolkits you use with --toolkits), so your handler is type-checked against the trigger's payload:

composio generate --toolkits github   # auto-detects TypeScript or Python

Webhook payload shape

Every trigger event arrives in the same envelope. metadata tells you where the event came from; data holds the event itself, in the shape the trigger type declares.

{
  "id": "msg_abc123",
  "type": "composio.trigger.message",
  "metadata": {
    "log_id": "log_abc123",
    "trigger_slug": "GITHUB_COMMIT_EVENT",
    "trigger_id": "ti_xyz789",
    "connected_account_id": "ca_def456",
    "auth_config_id": "ac_xyz789",
    "user_id": "user-id-123435"
  },
  "data": {
    "commit_sha": "a1b2c3d",
    "message": "fix: resolve null pointer",
    "author": "jane"
  },
  "timestamp": "2026-01-15T10:30:00Z"
}
metadata fieldWhat it tells you
trigger_idWhich trigger instance fired this event
trigger_slugThe trigger type (for example GITHUB_COMMIT_EVENT)
connected_account_idWhich connected account it belongs to
user_idWhich user it's for
auth_config_idWhich auth config was used

This is the V3 payload, the default for new organizations. See webhook payload versions for V2 and V1.

Verifying signatures

Composio signs every webhook request. parse({ verifySecret }) verifies the signature for you (and verifyWebhook() does the same at a lower level), so most handlers need nothing more. You only need this section if you're not using the Composio SDK.

Store the webhook secret securely as COMPOSIO_WEBHOOK_SECRET. Fetch it from the webhook subscription any time, or rotate it if it leaks.

Every request includes webhook-signature, webhook-id, and webhook-timestamp headers. Compute HMAC-SHA256 over {webhook-id}.{webhook-timestamp}.{rawBody} with your secret and compare it against the signature:

import hmac
import hashlib
import base64
import json
import os

def verify_webhook(webhook_id: str, webhook_timestamp: str, body: str, signature: str) -> dict:
    secret = os.getenv("COMPOSIO_WEBHOOK_SECRET", "")
    signing_string = f"{webhook_id}.{webhook_timestamp}.{body}"
    expected = base64.b64encode(
        hmac.new(secret.encode(), signing_string.encode(), hashlib.sha256).digest()
    ).decode()
    received = signature.split(",", 1)[1] if "," in signature else signature
    if not hmac.compare_digest(expected, received):
        raise ValueError("Invalid webhook signature")

    payload = json.loads(body)
    # V3 payload
    return {
        "trigger_slug": payload["metadata"]["trigger_slug"],
        "data": payload["data"],
    }
function verifyWebhook(
  webhookId: string,
  webhookTimestamp: string,
  body: string,
  signature: string
) {
  const secret = process.env.COMPOSIO_WEBHOOK_SECRET ?? '';
  const signingString = `${webhookId}.${webhookTimestamp}.${body}`;
  const expected = crypto
    .createHmac('sha256', secret)
    .update(signingString)
    .digest('base64');
  const received = signature.split(',')[1] ?? signature;
  if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received))) {
    throw new Error('Invalid webhook signature');
  }

  const payload = JSON.parse(body);
  // V3 payload
  return {
    triggerSlug: payload.metadata.trigger_slug,
    data: payload.data,
  };
}

Reject requests whose webhook-timestamp is too old to block replays. The SDK's parse() and verifyWebhook() enforce a 300-second tolerance by default; pass tolerance to change it, or 0 to disable the check.

Webhook payload versions

parse() and verifyWebhook() auto-detect the version. If you process payloads manually, here are the formats:

Metadata is separated from event data. New organizations receive V3 payloads by default.

{
  "id": "msg_abc123",
  "type": "composio.trigger.message",
  "metadata": {
    "log_id": "log_abc123",
    "trigger_slug": "GITHUB_COMMIT_EVENT",
    "trigger_id": "ti_xyz789",
    "connected_account_id": "ca_def456",
    "auth_config_id": "ac_xyz789",
    "user_id": "user-id-123435"
  },
  "data": {
    "commit_sha": "a1b2c3d",
    "message": "fix: resolve null pointer",
    "author": "jane"
  },
  "timestamp": "2026-01-15T10:30:00Z"
}

Metadata fields are mixed into the data object alongside event data.

{
  "type": "github_commit_event",
  "data": {
    "commit_sha": "a1b2c3d",
    "message": "fix: resolve null pointer",
    "author": "jane",
    "connection_id": "ca_def456",
    "connection_nano_id": "cn_abc123",
    "trigger_nano_id": "tn_xyz789",
    "trigger_id": "ti_xyz789",
    "user_id": "user-id-123435"
  },
  "timestamp": "2026-01-15T10:30:00Z",
  "log_id": "log_abc123"
}
{
  "trigger_name": "github_commit_event",
  "trigger_id": "ti_xyz789",
  "connection_id": "ca_def456",
  "payload": {
    "commit_sha": "a1b2c3d",
    "message": "fix: resolve null pointer",
    "author": "jane"
  },
  "log_id": "log_abc123"
}

Next

Managing triggers

List, enable, disable, and delete trigger instances