Verifying webhooks

Markdown

Composio signs every webhook request. Always verify signatures in production to ensure payloads are authentic.

Two signatures, two responsibilities. Composio verifies provider signatures on the ingress hop (provider → Composio); you verify Composio's signature on the delivery hop (Composio → your endpoint). This page covers the delivery hop. Ingress verification happens automatically — see Ingress signature verification below.

SDK verification

The SDK handles signature verification, payload parsing, and version detection (V1, V2, V3).

Your webhook secret is returned only once: when you create a webhook subscription or rotate the secret. If you didn't copy it at creation time, rotate it to get a new one. Store it securely as COMPOSIO_WEBHOOK_SECRET.

try:
    result = composio.triggers.verify_webhook(
        id=request.headers.get("webhook-id", ""),
        payload=request.get_data(as_text=True),
        signature=request.headers.get("webhook-signature", ""),
        timestamp=request.headers.get("webhook-timestamp", ""),
        secret=os.getenv("COMPOSIO_WEBHOOK_SECRET", ""),
    )
    # result.version, result.payload, result.raw_payload
except Exception:
    return {"error": "Invalid signature"}, 401
try {
  const result = await composio.triggers.verifyWebhook({
    id: req.headers['webhook-id'],
    payload: req.body,
    signature: req.headers['webhook-signature'],
    timestamp: req.headers['webhook-timestamp'],
    secret: process.env.COMPOSIO_WEBHOOK_SECRET!,
  });
  // result.version, result.payload, result.rawPayload
} catch (error) {
  // Return 401
}

An optional tolerance parameter (default: 300 seconds) controls how old a webhook can be before verification fails. Set to 0 to disable timestamp validation.

Manual verification

If you are not using the Composio SDK and want to verify signatures manually.

Your webhook secret is returned only once: when you create a webhook subscription or rotate the secret. If you didn't copy it at creation time, rotate it to get a new one. Store it securely as COMPOSIO_WEBHOOK_SECRET.

Every webhook request includes three headers: webhook-signature, webhook-id, and webhook-timestamp. Use these along with the raw request body to verify 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,
  };
}

Webhook payload versions

verifyWebhook() auto-detects 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"
}

Ingress signature verification

Composio verifies the provider's signature on every inbound request against the signing secret stored on the webhook endpoint before any trigger fires. Unsigned or tampered requests are rejected at ingress with 400, so third parties cannot spoof events onto your triggers.

This hop is automatic — you don't write any code. Your responsibility is just to make sure the signing secret on the endpoint is correct:

  • Composio-managed OAuth apps — Composio sources the signing secret from the connected user's credentials; nothing to do.
  • Your own OAuth app on a trigger whose requires_webhook_endpoint flag is truePATCH the signing secret onto the endpoint as part of Configuring the webhook endpoint:
curl -X PATCH "https://backend.composio.dev/api/v3.1/webhook_endpoints/<ENDPOINT_ID>" \
  -H "x-api-key: <YOUR_COMPOSIO_API_KEY>" \
  -H "Content-Type: application/json" \
  -d '{ "data": { "webhook_signing_secret": "<SIGNING_SECRET>" } }'

Composio uses the algorithm each provider expects — HMAC-SHA256 for Slack, Ed25519 or shared-token matching for others. For providers that sign a request timestamp (Slack does), replay protection additionally rejects requests outside the allowed skew window.