Files
2026-07-13 12:38:34 +08:00

1696 lines
65 KiB
Python

from __future__ import annotations
import base64
import functools
import hashlib
import hmac
import json
import time
import traceback
import typing as t
import uuid
from concurrent.futures import ThreadPoolExecutor
from enum import Enum
from unittest import mock
import typing_extensions as te
from composio_client import APIStatusError, Omit, omit
from composio_client.types import TriggersTypeRetrieveResponse
from pysher import Pusher
from pysher.channel import Channel as PusherChannel
from pysher.connection import Connection as PusherConnection
from composio import exceptions
from composio.client import HttpClient
from composio.client.types import trigger_instance_upsert_response
from composio.core.models.base import Resource
from composio.core.models.internal import Internal
from composio.core.types import ToolkitVersionParam
from composio.exceptions import ComposioSDKTimeoutError
from composio.utils.logging import WithLogger
from composio.utils.pydantic import none_to_omit
PUSHER_AUTH_URL = "{base_url}/api/v3/internal/sdk/realtime/auth?source=python"
"""
export type _TriggerData = {
appName: string;
clientId: number;
payload: Record<string, unknown>;
originalPayload: Record<string, unknown>;
metadata: {
id: string;
nanoId: string;
triggerName: string;
triggerData: string;
triggerConfig: Record<string, unknown>;
connection: {
id: string;
connectedAccountNanoId: string;
integrationId: string;
authConfigNanoId: string;
clientUniqueUserId: string;
status: string;
};
};
};
"""
class _ConnectionData(te.TypedDict):
id: str
status: t.Literal["ACTIVE", "INACTIVE"]
integrationId: str
authConfigNanoId: str
clientUniqueUserId: str
connectedAccountNanoId: str
class _TriggerMetadata(te.TypedDict):
id: str
nanoId: str
triggerName: str
triggerData: str
triggerConfig: t.Dict
connection: _ConnectionData
class _TriggerData(te.TypedDict):
clientId: str
appName: str
payload: t.Dict
metadata: _TriggerMetadata
originalPayload: t.Dict
class WebhookVersion(str, Enum):
"""Webhook payload version."""
V1 = "V1"
V2 = "V2"
V3 = "V3"
class WebhookPayloadV1(te.TypedDict):
"""V1 webhook payload structure."""
trigger_name: str
connection_id: str
trigger_id: str
payload: t.Dict[str, t.Any]
log_id: str
class WebhookPayloadV2(te.TypedDict):
"""V2 webhook payload structure."""
type: str
timestamp: str
log_id: str
data: t.Dict[str, t.Any]
class WebhookPayloadV3(te.TypedDict):
"""V3 webhook payload structure - generic envelope for all composio.* events."""
id: str
timestamp: str
type: str # Any composio.* event type (e.g., 'composio.trigger.message', 'composio.connected_account.expired')
metadata: t.Dict[
str, t.Any
] # Shape varies by event type (trigger vs connection events)
data: t.Dict[str, t.Any]
class WebhookTriggerPayloadV3Metadata(te.TypedDict):
"""V3 trigger-specific webhook payload metadata."""
log_id: str
trigger_slug: str
trigger_id: str
connected_account_id: str
auth_config_id: str
user_id: str
class WebhookTriggerPayloadV3(te.TypedDict):
"""V3 trigger-specific webhook payload structure."""
id: str
timestamp: str
type: str
metadata: WebhookTriggerPayloadV3Metadata
data: t.Dict[str, t.Any]
WebhookPayload = t.Union[WebhookPayloadV1, WebhookPayloadV2, WebhookPayloadV3]
class TriggerConnectedAccountSchema(t.TypedDict):
id: str
uuid: str
user_id: str
auth_config_id: str
auth_config_uuid: str
status: t.Literal["ACTIVE", "INACTIVE"]
class TriggerMetadataSchema(t.TypedDict):
id: str
toolkit_slug: str
trigger_slug: str
trigger_data: t.Optional[str]
trigger_config: t.Dict[str, t.Any]
connected_account: TriggerConnectedAccountSchema
class TriggerEvent(t.TypedDict):
id: str # The ID of the trigger
uuid: str # UUID of the trigger
user_id: str # The ID of the user that triggered the event
trigger_slug: str # The slug of the trigger that triggered the event
toolkit_slug: str # The slug of the toolkit that triggered the event
payload: t.Optional[t.Dict[str, t.Any]] # The payload of the trigger
metadata: TriggerMetadataSchema
original_payload: t.Optional[
t.Dict[str, t.Any]
] # The original payload of the trigger
class VerifyWebhookResult(t.TypedDict):
"""Result of webhook verification."""
version: WebhookVersion # The webhook version (V1, V2, or V3)
payload: TriggerEvent # The parsed and normalized webhook payload
raw_payload: WebhookPayload # The original parsed payload
class WebhookSubscription(t.TypedDict, total=False):
"""Webhook subscription returned by the Composio API."""
id: str
webhook_url: str
version: str
enabled_events: t.List[str]
secret: str
created_at: str
updated_at: str
DEFAULT_WEBHOOK_SUBSCRIPTION_EVENTS = ("composio.trigger.message",)
WEBHOOK_SUBSCRIPTIONS_PATH = "/api/v3.1/webhook_subscriptions"
_ = {
"appName": "github",
"payload": {
"author": "angrybayblade",
"id": "a2334682759c4324e911d8f52f8fb6bdf1338d94",
"message": "temp",
"timestamp": "2025-06-19T11:28:01+05:30",
"url": "https://github.com/angrybayblade/ph7/commit/a2334682759c4324e911d8f52f8fb6bdf1338d94",
},
"originalPayload": {
"ref": "refs/heads/temp",
"before": "e5eb72bc9f973b6cc82e0de9708660c5341befe5",
"after": "a2334682759c4324e911d8f52f8fb6bdf1338d94",
"repository": {
"id": 752122629,
"node_id": "R_kgDOLNR7BQ",
"name": "ph7",
"full_name": "angrybayblade/ph7",
"private": False,
"owner": {
"name": "angrybayblade",
"email": "35092918+angrybayblade@users.noreply.github.com",
"login": "angrybayblade",
"id": 35092918,
"node_id": "MDQ6VXNlcjM1MDkyOTE4",
"avatar_url": "https://avatars.githubusercontent.com/u/35092918?v=4",
"gravatar_id": "",
"url": "https://api.github.com/users/angrybayblade",
"html_url": "https://github.com/angrybayblade",
"followers_url": "https://api.github.com/users/angrybayblade/followers",
"following_url": "https://api.github.com/users/angrybayblade/following{/other_user}",
"gists_url": "https://api.github.com/users/angrybayblade/gists{/gist_id}",
"starred_url": "https://api.github.com/users/angrybayblade/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/angrybayblade/subscriptions",
"organizations_url": "https://api.github.com/users/angrybayblade/orgs",
"repos_url": "https://api.github.com/users/angrybayblade/repos",
"events_url": "https://api.github.com/users/angrybayblade/events{/privacy}",
"received_events_url": "https://api.github.com/users/angrybayblade/received_events",
"type": "User",
"user_view_type": "public",
"site_admin": False,
},
"html_url": "https://github.com/angrybayblade/ph7",
"description": "💧 Python native HTML rendering",
"fork": False,
"url": "https://api.github.com/repos/angrybayblade/ph7",
"forks_url": "https://api.github.com/repos/angrybayblade/ph7/forks",
"keys_url": "https://api.github.com/repos/angrybayblade/ph7/keys{/key_id}",
"collaborators_url": "https://api.github.com/repos/angrybayblade/ph7/collaborators{/collaborator}",
"teams_url": "https://api.github.com/repos/angrybayblade/ph7/teams",
"hooks_url": "https://api.github.com/repos/angrybayblade/ph7/hooks",
"issue_events_url": "https://api.github.com/repos/angrybayblade/ph7/issues/events{/number}",
"events_url": "https://api.github.com/repos/angrybayblade/ph7/events",
"assignees_url": "https://api.github.com/repos/angrybayblade/ph7/assignees{/user}",
"branches_url": "https://api.github.com/repos/angrybayblade/ph7/branches{/branch}",
"tags_url": "https://api.github.com/repos/angrybayblade/ph7/tags",
"blobs_url": "https://api.github.com/repos/angrybayblade/ph7/git/blobs{/sha}",
"git_tags_url": "https://api.github.com/repos/angrybayblade/ph7/git/tags{/sha}",
"git_refs_url": "https://api.github.com/repos/angrybayblade/ph7/git/refs{/sha}",
"trees_url": "https://api.github.com/repos/angrybayblade/ph7/git/trees{/sha}",
"statuses_url": "https://api.github.com/repos/angrybayblade/ph7/statuses/{sha}",
"languages_url": "https://api.github.com/repos/angrybayblade/ph7/languages",
"stargazers_url": "https://api.github.com/repos/angrybayblade/ph7/stargazers",
"contributors_url": "https://api.github.com/repos/angrybayblade/ph7/contributors",
"subscribers_url": "https://api.github.com/repos/angrybayblade/ph7/subscribers",
"subscription_url": "https://api.github.com/repos/angrybayblade/ph7/subscription",
"commits_url": "https://api.github.com/repos/angrybayblade/ph7/commits{/sha}",
"git_commits_url": "https://api.github.com/repos/angrybayblade/ph7/git/commits{/sha}",
"comments_url": "https://api.github.com/repos/angrybayblade/ph7/comments{/number}",
"issue_comment_url": "https://api.github.com/repos/angrybayblade/ph7/issues/comments{/number}",
"contents_url": "https://api.github.com/repos/angrybayblade/ph7/contents/{+path}",
"compare_url": "https://api.github.com/repos/angrybayblade/ph7/compare/{base}...{head}",
"merges_url": "https://api.github.com/repos/angrybayblade/ph7/merges",
"archive_url": "https://api.github.com/repos/angrybayblade/ph7/{archive_format}{/ref}",
"downloads_url": "https://api.github.com/repos/angrybayblade/ph7/downloads",
"issues_url": "https://api.github.com/repos/angrybayblade/ph7/issues{/number}",
"pulls_url": "https://api.github.com/repos/angrybayblade/ph7/pulls{/number}",
"milestones_url": "https://api.github.com/repos/angrybayblade/ph7/milestones{/number}",
"notifications_url": "https://api.github.com/repos/angrybayblade/ph7/notifications{?since,all,participating}",
"labels_url": "https://api.github.com/repos/angrybayblade/ph7/labels{/name}",
"releases_url": "https://api.github.com/repos/angrybayblade/ph7/releases{/id}",
"deployments_url": "https://api.github.com/repos/angrybayblade/ph7/deployments",
"created_at": 1706936166,
"updated_at": "2025-03-22T17:26:27Z",
"pushed_at": 1750312684,
"git_url": "git://github.com/angrybayblade/ph7.git",
"ssh_url": "git@github.com:angrybayblade/ph7.git",
"clone_url": "https://github.com/angrybayblade/ph7.git",
"svn_url": "https://github.com/angrybayblade/ph7",
"homepage": "https://angrybayblade.github.io/ph7/",
"size": 3031,
"stargazers_count": 7,
"watchers_count": 7,
"language": "Python",
"has_issues": True,
"has_projects": True,
"has_downloads": True,
"has_wiki": True,
"has_pages": True,
"has_discussions": False,
"forks_count": 1,
"mirror_url": None,
"archived": False,
"disabled": False,
"open_issues_count": 0,
"license": None,
"allow_forking": True,
"is_template": False,
"web_commit_signoff_required": False,
"topics": [
"css",
"django",
"flask",
"html",
"js",
"template-engine",
"web",
],
"visibility": "public",
"forks": 1,
"open_issues": 0,
"watchers": 7,
"default_branch": "main",
"stargazers": 7,
"master_branch": "main",
},
"pusher": {
"name": "angrybayblade",
"email": "35092918+angrybayblade@users.noreply.github.com",
},
"sender": {
"login": "angrybayblade",
"id": 35092918,
"node_id": "MDQ6VXNlcjM1MDkyOTE4",
"avatar_url": "https://avatars.githubusercontent.com/u/35092918?v=4",
"gravatar_id": "",
"url": "https://api.github.com/users/angrybayblade",
"html_url": "https://github.com/angrybayblade",
"followers_url": "https://api.github.com/users/angrybayblade/followers",
"following_url": "https://api.github.com/users/angrybayblade/following{/other_user}",
"gists_url": "https://api.github.com/users/angrybayblade/gists{/gist_id}",
"starred_url": "https://api.github.com/users/angrybayblade/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/angrybayblade/subscriptions",
"organizations_url": "https://api.github.com/users/angrybayblade/orgs",
"repos_url": "https://api.github.com/users/angrybayblade/repos",
"events_url": "https://api.github.com/users/angrybayblade/events{/privacy}",
"received_events_url": "https://api.github.com/users/angrybayblade/received_events",
"type": "User",
"user_view_type": "public",
"site_admin": False,
},
"created": False,
"deleted": False,
"forced": False,
"base_ref": None,
"compare": "https://github.com/angrybayblade/ph7/compare/e5eb72bc9f97...a2334682759c",
"commits": [
{
"id": "a2334682759c4324e911d8f52f8fb6bdf1338d94",
"tree_id": "ed0ae8c902cb2a0e12d3c64aea8aab66f7b06054",
"distinct": True,
"message": "temp",
"timestamp": "2025-06-19T11:28:01+05:30",
"url": "https://github.com/angrybayblade/ph7/commit/a2334682759c4324e911d8f52f8fb6bdf1338d94",
"author": {
"name": "angrybayblade",
"email": "vptl185@gmail.com",
"username": "angrybayblade",
},
"committer": {
"name": "angrybayblade",
"email": "vptl185@gmail.com",
"username": "angrybayblade",
},
"added": [],
"removed": [],
"modified": ["mkdocs.yml"],
}
],
"head_commit": {
"id": "a2334682759c4324e911d8f52f8fb6bdf1338d94",
"tree_id": "ed0ae8c902cb2a0e12d3c64aea8aab66f7b06054",
"distinct": True,
"message": "temp",
"timestamp": "2025-06-19T11:28:01+05:30",
"url": "https://github.com/angrybayblade/ph7/commit/a2334682759c4324e911d8f52f8fb6bdf1338d94",
"author": {
"name": "angrybayblade",
"email": "vptl185@gmail.com",
"username": "angrybayblade",
},
"committer": {
"name": "angrybayblade",
"email": "vptl185@gmail.com",
"username": "angrybayblade",
},
"added": [],
"removed": [],
"modified": ["mkdocs.yml"],
},
},
"metadata": {
"id": "2507236e-4be2-4606-b338-fb24c76ce38d",
"nanoId": "ti_VZEWUqF5fQ6P",
"connectionId": "75271255-fe95-4d34-bee3-459a02645e38",
"connectionNanoId": "ca_5KdA-e2C4ZMd",
"triggerName": "GITHUB_COMMIT_EVENT",
"triggerData": '{"event_type": "push", "github_hook_id": "552965247"}',
"triggerConfig": {"repo": "ph7", "owner": "angrybayblade"},
"connection": {
"id": "75271255-fe95-4d34-bee3-459a02645e38",
"connectedAccountNanoId": "ca_5KdA-e2C4ZMd",
"integrationId": "500748ea-8547-4abb-9f9e-10dbcfdb81c2",
"authConfigNanoId": "ac_ZxnpxqOo1nAP",
"clientUniqueUserId": "default",
"status": "ACTIVE",
},
},
}
class _ChunkedTriggerEventData(te.TypedDict):
"""Cunked trigger event data model."""
id: str
index: int
chunk: str
final: bool
class TriggerEventFilters(te.TypedDict):
"""Trigger event filterset."""
trigger_slug: te.NotRequired[str]
trigger_id: te.NotRequired[str]
toolkit: te.NotRequired[str]
user_id: te.NotRequired[str]
auth_config_id: te.NotRequired[str]
connected_account_id: te.NotRequired[str]
TriggerCallback = t.Callable[[TriggerEvent], None]
# Realtime trigger frames can carry message bodies / PII, so the raw frame is
# never logged in full — only a bounded preview when it fails to parse.
_MAX_LOGGED_FRAME_CHARS = 512
def _truncate_frame(event: str) -> str:
"""Return a bounded preview of a raw frame for safe logging."""
if len(event) <= _MAX_LOGGED_FRAME_CHARS:
return event
return f"{event[:_MAX_LOGGED_FRAME_CHARS]}… ({len(event)} chars total)"
def _coerce_str(value: t.Any) -> str:
"""Coerce a possibly-missing or non-string field to ``str``.
Mirrors the TS SDK's ``toStringOrDefault`` so realtime/webhook frames that
carry ``None`` or a non-string identity field don't later raise
``AttributeError`` on ``.lower()`` / ``.split()``. ``None`` maps to "".
"""
return "" if value is None else str(value)
def _is_v3_envelope(data: t.Any) -> bool:
"""Return ``True`` if ``data`` is a modern V3 envelope.
V3 payloads carry a ``type`` starting with ``composio.`` and a ``metadata``
object, alongside top-level ``id`` and ``data`` keys. The same envelope is
delivered over both the webhook channel and the realtime (Pusher) channel,
so this is used to route either source through the V3 normalizer.
"""
if not isinstance(data, dict):
return False
event_type = data.get("type", "")
return (
isinstance(event_type, str)
and event_type.startswith("composio.")
and isinstance(data.get("metadata"), dict)
and "id" in data
and "data" in data
)
def _build_trigger_event_from_v3(data: WebhookPayloadV3) -> TriggerEvent:
"""Normalize a V3 envelope (webhook or realtime) into a ``TriggerEvent``.
Trigger frames are identified by their envelope ``type``
(``composio.trigger.message``), not by the presence of every metadata
field. The trigger-specific metadata fields are then read best-effort and
coerced to ``str`` so a frame that omits or mistypes one of them is still
delivered rather than silently demoted to a non-trigger event or raising
``AttributeError`` on the toolkit-slug split.
"""
metadata = data.get("metadata") or {}
event_type = data.get("type", "")
if event_type == "composio.trigger.message":
trigger_id = _coerce_str(metadata.get("trigger_id"))
trigger_slug = _coerce_str(metadata.get("trigger_slug"))
user_id = _coerce_str(metadata.get("user_id"))
connected_account_id = _coerce_str(metadata.get("connected_account_id"))
auth_config_id = _coerce_str(metadata.get("auth_config_id"))
toolkit_slug = (
trigger_slug.split("_")[0].upper() if "_" in trigger_slug else "UNKNOWN"
)
return t.cast(
TriggerEvent,
{
"id": trigger_id,
"uuid": trigger_id,
"user_id": user_id,
"toolkit_slug": toolkit_slug,
"trigger_slug": trigger_slug,
"metadata": {
"id": trigger_id,
"uuid": trigger_id,
"toolkit_slug": toolkit_slug,
"trigger_slug": trigger_slug,
"trigger_data": None,
"trigger_config": {},
"connected_account": {
"id": connected_account_id,
"uuid": connected_account_id,
"auth_config_id": auth_config_id,
"auth_config_uuid": auth_config_id,
"user_id": user_id,
"status": "ACTIVE",
},
},
"payload": data.get("data", {}),
"original_payload": None,
},
)
# Non-trigger V3 event (e.g., connection expired)
event_id = _coerce_str(data.get("id"))
return t.cast(
TriggerEvent,
{
"id": event_id,
"uuid": event_id,
"user_id": "",
"toolkit_slug": "COMPOSIO",
"trigger_slug": event_type,
"metadata": {
"id": event_id,
"uuid": event_id,
"toolkit_slug": "COMPOSIO",
"trigger_slug": event_type,
"trigger_data": None,
"trigger_config": {},
"connected_account": {
"id": "",
"uuid": "",
"auth_config_id": "",
"auth_config_uuid": "",
"user_id": "",
"status": "ACTIVE",
},
},
"payload": data.get("data", {}),
"original_payload": data,
},
)
class TriggerSubscription(Resource):
"""Trigger subscription."""
_pusher: Pusher
_channel: PusherChannel
_connection: PusherConnection
_alive: bool
def __init__(self, client: HttpClient) -> None:
"""Initialize subscription object."""
super().__init__(client=client)
self.client = client
self._alive = False
self._chunks: t.Dict[str, t.Dict[int, str]] = {}
self._callbacks: t.List[t.Tuple[TriggerCallback, TriggerEventFilters]] = []
def handle(
self, **filters: te.Unpack[TriggerEventFilters]
) -> t.Callable[[TriggerCallback], TriggerCallback]:
"""Register a trigger callaback."""
def _wrap(f: TriggerCallback) -> TriggerCallback:
self.logger.debug(f"Registering callback `{f.__name__}`")
self._callbacks.append((f, filters))
return f
return _wrap
def _parse_payload(self, event: str) -> t.Optional[TriggerEvent]:
"""Parse a realtime event payload into a ``TriggerEvent``.
The realtime (Pusher) channel delivers either the modern V3 envelope
(for projects on webhook version V3) or the legacy envelope (for V1/V2
projects). Both shapes are handled here. Any unrecognized or malformed
frame is logged and skipped (returns ``None``) rather than raising, so a
single bad frame cannot tear down the subscription.
"""
try:
data = json.loads(event)
except Exception as e:
self.logger.warning(f"Error decoding payload: {e}")
return None
try:
# V3 envelope — same shape as the V3 webhook payload.
if _is_v3_envelope(data):
return _build_trigger_event_from_v3(t.cast(WebhookPayloadV3, data))
# Legacy envelope (V1/V2 projects).
legacy = t.cast(_TriggerData, data)
return t.cast(
TriggerEvent,
{
"id": legacy["metadata"]["nanoId"],
"uuid": legacy["metadata"]["id"],
"user_id": legacy["metadata"]["connection"]["clientUniqueUserId"],
"toolkit_slug": legacy["appName"],
"trigger_slug": legacy["metadata"]["triggerName"],
"metadata": {
"id": legacy["metadata"]["nanoId"],
"uuid": legacy["metadata"]["id"],
"toolkit_slug": legacy["appName"],
"trigger_slug": legacy["metadata"]["triggerName"],
"trigger_data": legacy["metadata"].get("triggerData"),
"trigger_config": legacy["metadata"]["triggerConfig"],
"connected_account": {
"id": legacy["metadata"]["connection"][
"connectedAccountNanoId"
],
"uuid": legacy["metadata"]["connection"]["id"],
"auth_config_id": legacy["metadata"]["connection"][
"authConfigNanoId"
],
"auth_config_uuid": legacy["metadata"]["connection"][
"integrationId"
],
"user_id": legacy["metadata"]["connection"][
"clientUniqueUserId"
],
"status": legacy["metadata"]["connection"]["status"],
},
},
"payload": legacy.get("payload"),
"original_payload": legacy.get("originalPayload"),
},
)
except Exception as e:
# A single malformed frame must never propagate into pysher's
# dispatch loop (which calls callbacks with no try/except) and tear
# down the subscription, so catch broadly — AttributeError and
# others, not just KeyError/TypeError — and skip the frame.
self.logger.warning(f"Error parsing trigger payload: {e}")
return None
def _handle_chunked_events(self, event: str) -> None:
"""Handle chunked events."""
data = _ChunkedTriggerEventData(**json.loads(event)) # type: ignore
if data["id"] not in self._chunks:
self._chunks[data["id"]] = {}
self._chunks[data["id"]][data["index"]] = data["chunk"]
if data["final"]:
_chunks = self._chunks.pop(data["id"])
self._handle_event(event="".join([_chunks[idx] for idx in sorted(_chunks)]))
def _filters_match(
self,
data: TriggerEvent,
filters: TriggerEventFilters,
callback: str,
) -> bool:
"""Check if filters match the event data."""
checks = (
("trigger_slug", data["trigger_slug"]),
("trigger_id", data["metadata"]["id"]),
("toolkit", data["toolkit_slug"]),
("user_id", data["user_id"]),
("auth_config_id", data["metadata"]["connected_account"]["auth_config_id"]),
("connected_account_id", data["metadata"]["connected_account"]["id"]),
)
for name, check in checks:
value = filters.get(name)
if value is None:
# No filter set for this field — nothing to match against.
continue
# ``check`` is str()-wrapped because realtime frames may carry
# non-string identity fields; an empty event-side value is treated
# as a non-match so synthesized "" fields don't fail open into a
# filter that happens to be "".
check_str = str(check)
if check_str and str(value).lower() == check_str.lower():
continue
self.logger.debug(
f"Skipping `{callback}` since "
f"`{name}` filter does not match the event metadata",
)
return False
return True
def _handle_callback(
self,
callback: TriggerCallback,
data: TriggerEvent,
filters: TriggerEventFilters,
) -> t.Any:
"""Handle callback."""
if not self._filters_match(data, filters, callback.__name__):
return
try:
callback(data)
except Exception:
self.logger.error(
f"Error executing `{callback.__name__}` for "
f"event `{data['metadata']['trigger_slug']}` "
f"with error:\n {traceback.format_exc()}"
)
def _handle_event(self, event: str) -> None:
"""Filter events and call the callback function."""
data = self._parse_payload(event=event)
if data is None:
# _parse_payload already logged the specific reason; here we only
# note that the frame was skipped, with a bounded preview so a
# PII-bearing frame isn't dumped in full.
self.logger.error(
f"Skipping unparseable trigger frame: {_truncate_frame(event)}"
)
return
self.logger.debug(
f"Received trigger event with trigger ID: {data['metadata']['id']} "
f"and trigger name: {data['metadata']['trigger_slug']}"
)
awaitables: t.List = []
with ThreadPoolExecutor() as executor:
for callback, filters in self._callbacks:
awaitables.append(
executor.submit(
self._handle_callback,
callback,
data,
filters,
)
)
_ = [future.result() for future in awaitables]
def is_alive(self) -> bool:
"""Check if subscription is live."""
return self._alive
def has_errored(self) -> bool:
"""Check if the connection errored and disconnected."""
return self._connection.socket is None or self._connection.socket.has_errored
def set_alive(self) -> None:
"""Set `_alive` to True."""
self._alive = True
def wait_forever(self) -> None:
"""Wait infinitely."""
while self.is_alive() and not self.has_errored():
time.sleep(1)
def stop(self) -> None:
"""Stop the trigger listener."""
self._connection.disconnect()
self._alive = False
def restart(self) -> None:
"""Restart the subscription connection"""
self._connection.disconnect()
self._connection._connect() # pylint: disable=protected-access
class _SubcriptionBuilder(WithLogger):
"""Pusher client for Composio SDK."""
def __init__(self, client: HttpClient) -> None:
"""Initialize pusher client."""
super().__init__()
self._client = client
self.api_key = self._client.api_key
self.base_url = self._client.base_url
self.internal = Internal(client=self._client)
self.subscription = TriggerSubscription(client=self._client)
def _get_connection_handler(
self,
project_id: str,
pusher: Pusher,
subscription: TriggerSubscription,
) -> t.Callable[[str], None]:
def _connection_handler(_: str) -> None:
channel = t.cast(
PusherChannel,
pusher.subscribe(
channel_name=f"private-{project_id}_triggers",
),
)
channel.bind(
event_name="trigger_to_client",
callback=subscription._handle_event,
)
channel.bind(
event_name="chunked-trigger_to_client",
callback=subscription._handle_chunked_events,
)
subscription.set_alive()
subscription._channel = channel # pylint: disable=protected-access
subscription._connection = ( # pylint: disable=protected-access
channel.connection
)
return _connection_handler
def _get_pusher_instance(self, key: str, cluster: str) -> Pusher:
"""Get a pusher instance."""
return Pusher(
key=key,
cluster=cluster,
auth_endpoint=PUSHER_AUTH_URL.format(base_url=self._client.base_url),
auth_endpoint_headers={
"x-api-key": self._client.api_key,
"x-request-id": str(uuid.uuid4()),
},
auto_sub=True,
)
def connect(self, timeout: float = 15.0) -> TriggerSubscription:
"""Connect to Pusher channel for given client ID."""
self.logger.debug("Creating trigger subscription")
project_info = self.internal.get_sdk_realtime_credentials()
pusher = self._get_pusher_instance(
key=project_info.pusher_key,
cluster=project_info.pusher_cluster,
)
# Patch pusher logger
pusher.connection.logger = mock.MagicMock() # type: ignore
pusher.connection.bind(
"pusher:connection_established",
self._get_connection_handler(
project_id=project_info.project_id,
pusher=pusher,
subscription=self.subscription,
),
)
pusher.connect()
# Wait for connection to get established
deadline = time.time() + timeout
while time.time() < deadline:
if not self.subscription.is_alive():
time.sleep(0.5)
continue
self.subscription._pusher = pusher # pylint: disable=protected-access
return self.subscription
raise ComposioSDKTimeoutError(
"Timed out while waiting for trigger listener to be established"
)
class Triggers(Resource):
"""Triggers (instance) class"""
enable: t.Callable
"""Enables a trigger given its id"""
disable: t.Callable
"""Disables a trigger given its id"""
def __init__(
self,
client: HttpClient,
toolkit_versions: t.Optional[ToolkitVersionParam] = None,
):
"""
Initialize the triggers resource.
:param client: The client to use for the triggers resource.
:param toolkit_versions: The versions of the toolkits to use. Defaults to 'latest' if not provided.
"""
self._client = client
self._toolkit_versions = toolkit_versions
self.list_enum = self._client.triggers_types.retrieve_enum
self.delete = self._client.trigger_instances.manage.delete
self.enable = functools.partial(
self._client.trigger_instances.manage.update,
status="enable",
)
self.disable = functools.partial(
self._client.trigger_instances.manage.update,
status="disable",
)
def set_webhook_subscription(
self,
*,
webhook_url: str,
enabled_events: t.Optional[t.Sequence[str]] = None,
version: t.Union[WebhookVersion, str] = WebhookVersion.V3,
) -> WebhookSubscription:
"""
Create or update the project webhook subscription used for webhook delivery.
If a subscription already exists, the first subscription is updated. Otherwise a
new subscription is created. By default this subscribes to V3 trigger message
events.
Example:
composio.triggers.set_webhook_subscription(
webhook_url=f"{APP_URL}/webhooks/composio",
)
"""
if not webhook_url:
raise exceptions.ValidationError("please provide a valid `webhook_url`")
events = list(
DEFAULT_WEBHOOK_SUBSCRIPTION_EVENTS
if enabled_events is None
else enabled_events
)
if len(events) == 0:
raise exceptions.ValidationError(
"please provide at least one enabled event"
)
version_value = (
version.value if isinstance(version, WebhookVersion) else version
)
body = {
"webhook_url": webhook_url,
"enabled_events": events,
"version": version_value,
}
existing = self._client.get(
WEBHOOK_SUBSCRIPTIONS_PATH,
cast_to=object,
options={"params": {"limit": 1}},
)
subscription_id = self._first_webhook_subscription_id(existing)
if subscription_id:
return self._normalize_webhook_subscription(
self._client.patch(
f"{WEBHOOK_SUBSCRIPTIONS_PATH}/{subscription_id}",
cast_to=object,
body=body,
)
)
return self._normalize_webhook_subscription(
self._client.post(
WEBHOOK_SUBSCRIPTIONS_PATH,
cast_to=object,
body=body,
),
)
@staticmethod
def _normalize_webhook_subscription(raw: object) -> WebhookSubscription:
"""Build a typed :class:`WebhookSubscription` from the raw API response.
Maps explicitly (accepting either snake_case or camelCase wire keys)
instead of ``cast``-ing the raw object, so the returned dict always
matches the declared shape and a shift in the wire format surfaces as a
normalized field rather than a ``KeyError`` at the call site.
"""
data = raw if isinstance(raw, dict) else {}
def _first_str(*keys: str) -> t.Optional[str]:
for key in keys:
value = data.get(key)
if isinstance(value, str) and value:
return value
return None
def _str_list(*keys: str) -> t.List[str]:
for key in keys:
value = data.get(key)
if isinstance(value, list):
return [item for item in value if isinstance(item, str)]
return []
result: WebhookSubscription = {
"id": _first_str("id") or "",
"webhook_url": _first_str("webhook_url", "webhookUrl") or "",
"version": _first_str("version") or WebhookVersion.V3.value,
"enabled_events": _str_list("enabled_events", "enabledEvents"),
}
secret = _first_str("secret")
if secret is not None:
result["secret"] = secret
created_at = _first_str("created_at", "createdAt")
if created_at is not None:
result["created_at"] = created_at
updated_at = _first_str("updated_at", "updatedAt")
if updated_at is not None:
result["updated_at"] = updated_at
return result
@staticmethod
def _first_webhook_subscription_id(response: object) -> t.Optional[str]:
if not isinstance(response, dict):
return None
items = response.get("items")
if not isinstance(items, list) or len(items) == 0:
return None
first = items[0]
if not isinstance(first, dict):
return None
subscription_id = first.get("id")
return subscription_id if isinstance(subscription_id, str) else None
def get_type(self, slug: str) -> TriggersTypeRetrieveResponse:
"""
Get a trigger type by its slug
Uses the global toolkit version provided when initializing composio instance to fetch trigger for specific toolkit version
:param slug: The slug of the trigger type
:return: The trigger type
"""
return self._client.triggers_types.retrieve(
slug=slug, toolkit_versions=none_to_omit(self._toolkit_versions)
)
def list_active(
self,
trigger_ids: t.Optional[list[str]] = None,
trigger_names: t.Optional[list[str]] = None,
auth_config_ids: t.Optional[list[str]] = None,
connected_account_ids: t.Optional[list[str]] = None,
show_disabled: t.Optional[bool] = None,
limit: t.Optional[int] = None,
cursor: t.Optional[str] = None,
):
"""
List all active triggers
:param trigger_ids: List of trigger IDs to filter by
:param trigger_names: List of trigger names to filter by
:param auth_config_ids: List of auth config IDs to filter by
:param connected_account_ids: List of connected account IDs to filter by
:param show_disabled: Whether to show disabled triggers
:param limit: Limit the number of triggers to return
:param cursor: Cursor for pagination. Use the nextCursor from the response to get the next page.
:return: List of active triggers
"""
return self._client.trigger_instances.list_active(
query_trigger_ids_1=trigger_ids,
query_trigger_names_1=trigger_names,
query_auth_config_ids_1=auth_config_ids,
query_connected_account_ids_1=connected_account_ids,
query_show_disabled_1=show_disabled,
limit=limit if limit is not None else omit,
cursor=cursor if cursor is not None else omit,
)
def list(
self,
*,
cursor: t.Optional[str] = None,
limit: t.Optional[int] = None,
toolkit_slugs: t.Optional[list[str]] = None,
):
"""
List all the trigger types.
:param cursor: The cursor for pagination
:param limit: The maximum number of trigger types to return
:param toolkit_slugs: Filter by toolkit slugs
:return: The list of trigger types
"""
return self._client.triggers_types.list(
cursor=none_to_omit(cursor),
limit=none_to_omit(limit),
toolkit_slugs=none_to_omit(toolkit_slugs),
toolkit_versions=none_to_omit(self._toolkit_versions),
)
@t.overload
def create(
self,
slug: str,
*,
connected_account_id: str,
trigger_config: t.Optional[t.Dict[str, t.Any]] = None,
) -> trigger_instance_upsert_response.TriggerInstanceUpsertResponse: ...
@t.overload
def create(
self,
slug: str,
*,
user_id: str,
trigger_config: t.Optional[t.Dict[str, t.Any]] = None,
) -> trigger_instance_upsert_response.TriggerInstanceUpsertResponse: ...
def create(
self,
slug: str,
*,
user_id: t.Optional[str] = None,
connected_account_id: t.Optional[str] = None,
trigger_config: t.Optional[t.Dict[str, t.Any]] = None,
) -> trigger_instance_upsert_response.TriggerInstanceUpsertResponse:
"""
Create a trigger instance
:param slug: The slug of the trigger
:param user_id: The ID of the user that owns the connected account. When
``connected_account_id`` is omitted, the backend resolves the first
active connection for this user and the trigger's toolkit.
:param connected_account_id: The ID of the connected account to pin
:param trigger_config: The configuration of the trigger
:return: The trigger instance
"""
# Treat blank strings as missing so an empty or whitespace-only
# `user_id` / `connected_account_id` does not slip past the guard below
# or reach the backend (parity with the TypeScript SDK's `userId` check).
if isinstance(user_id, str) and not user_id.strip():
user_id = None
if isinstance(connected_account_id, str) and not connected_account_id.strip():
connected_account_id = None
if user_id is None and connected_account_id is None:
raise exceptions.InvalidParams(
"please provide valid `connected_account_id` or `user_id`"
)
# Validate the trigger slug up-front so callers get a clear
# `TriggerTypeNotFound` (parity with the TypeScript SDK).
try:
self.get_type(slug=slug)
except APIStatusError as error:
if error.status_code in (400, 404):
raise exceptions.TriggerTypeNotFound(
f"Trigger type {slug} not found"
) from error
raise
# Pass user_id straight through: when connected_account_id is omitted the
# backend resolves the first active connection for this user and the
# trigger's toolkit (parity with tool execution). When 2FA is enabled and
# connected_account_id is pinned, the backend validates that user_id owns it.
return self._client.trigger_instances.upsert(
slug=slug,
connected_account_id=none_to_omit(connected_account_id),
toolkit_versions=self._toolkit_versions,
body_trigger_config_1=none_to_omit(trigger_config),
user_id=none_to_omit(user_id),
)
def subscribe(self, timeout: float = 15.0) -> TriggerSubscription:
"""
Subscribe to a trigger and receive trigger events.
:param timeout: The timeout to wait for the subscription to be established.
:return: The trigger subscription handler.
"""
return _SubcriptionBuilder(client=self._client).connect(timeout=timeout)
def verify_webhook(
self,
*,
id: str,
payload: str,
secret: str,
signature: str,
timestamp: str,
tolerance: int = 300,
) -> VerifyWebhookResult:
"""
Verify an incoming webhook payload and signature.
This method validates that the webhook request is authentic by:
1. Validating the webhook timestamp is within the tolerance window
2. Verifying the HMAC-SHA256 signature using the correct algorithm
3. Parsing the payload and detecting the webhook version (V1, V2, or V3)
:param id: The webhook message ID from the 'webhook-id' header (format: 'msg_xxx')
:param payload: The raw webhook payload as a string (request body)
:param secret: The webhook secret used to sign the payload (from Composio dashboard)
:param signature: The signature from the 'webhook-signature' header (format: 'v1,base64EncodedSignature')
:param timestamp: The webhook timestamp from the 'webhook-timestamp' header (Unix seconds)
:param tolerance: Maximum allowed age of the webhook in seconds (default: 300 = 5 minutes).
Set to 0 to disable timestamp validation.
:return: VerifyWebhookResult containing version, normalized payload, and raw payload
:raises WebhookSignatureVerificationError: If the signature verification fails
:raises WebhookPayloadError: If the payload cannot be parsed or is invalid
Example:
# In a Flask webhook handler
@app.route('/webhook', methods=['POST'])
def webhook():
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.environ['COMPOSIO_WEBHOOK_SECRET'],
)
# Process the verified payload
print(f"Version: {result['version']}")
print(f"Received trigger: {result['payload']['trigger_slug']}")
return 'OK', 200
except WebhookSignatureVerificationError:
return 'Unauthorized', 401
"""
# Validate timestamp if tolerance is set
if tolerance > 0:
self._validate_webhook_timestamp_header(timestamp, tolerance)
# Verify signature using the correct algorithm
self._verify_webhook_signature(
webhook_id=id,
webhook_timestamp=timestamp,
payload=payload,
signature=signature,
secret=secret,
)
# Parse and detect version
version, raw_payload, normalized_payload = self._parse_webhook_payload(payload)
return {
"version": version,
"payload": normalized_payload,
"raw_payload": raw_payload,
}
def parse(
self,
request: t.Any = None,
*,
body: t.Union[str, bytes, t.Mapping[str, t.Any], None] = None,
headers: t.Union[t.Mapping[str, t.Any], None] = None,
verify_secret: t.Union[str, None, Omit] = omit,
tolerance: int = 300,
) -> VerifyWebhookResult:
"""
Parse an incoming webhook request into a typed, normalized trigger payload.
Pass a framework request object, or pass ``body=`` and ``headers=``
explicitly. When ``verify_secret`` is provided, the SDK verifies the
webhook signature before returning the normalized trigger payload. When
it is omitted, the SDK parses the body without verifying the signature.
``request`` may be any object exposing the request body and headers, such
as a Flask, Django, or FastAPI request. The body is read from ``.body``
(or ``.data`` / ``.get_data()``), and the headers are read from
``.headers``. Because this SDK is synchronous, async frameworks must pass
an already-read raw body, for example via ``body=await request.body()``.
:param request: The incoming webhook request object (Flask/Django/FastAPI)
:param body: The raw request body (str/bytes/parsed mapping); overrides ``request``
:param headers: The request headers as a mapping; overrides ``request``
:param verify_secret: Webhook secret; when set, the signature is verified.
Omit it entirely to parse without verification. Passing a present-but-empty
value (e.g. an unset ``COMPOSIO_WEBHOOK_SECRET``) raises rather than
silently skipping verification.
:param tolerance: Max webhook age in seconds (only used when verifying)
:return: VerifyWebhookResult containing version, normalized payload, and raw payload
:raises ValidationError: If ``verify_secret`` is empty, or is set but signature headers are missing
:raises WebhookSignatureVerificationError: If signature verification fails
:raises WebhookPayloadError: If the payload cannot be parsed
Example:
# Flask: verify the signature
@app.route('/webhooks/composio', methods=['POST'])
def webhook():
try:
result = composio.triggers.parse(
request,
verify_secret=os.environ['COMPOSIO_WEBHOOK_SECRET'],
)
print(f"Trigger: {result['payload']['trigger_slug']}")
print(f"Event data: {result['payload']['payload']}")
return 'OK', 200
except exceptions.WebhookSignatureVerificationError:
return 'Unauthorized', 401
# FastAPI: parse without verifying after reading the async body
@app.post('/webhooks/composio')
async def webhook(request: Request):
raw = await request.body()
result = composio.triggers.parse(body=raw, headers=request.headers)
return {'trigger': result['payload']['trigger_slug']}
"""
raw_body = body if body is not None else self._extract_request_body(request)
raw_headers = (
headers if headers is not None else getattr(request, "headers", None)
)
payload = self._body_to_str(raw_body)
# Distinguish "caller omitted verify_secret" (explicit opt-out) from
# "caller passed verify_secret but it resolved to empty" (almost always
# an unset COMPOSIO_WEBHOOK_SECRET). The latter must fail loudly rather
# than silently skip verification and accept forged events.
if isinstance(verify_secret, Omit):
version, raw_payload, normalized_payload = self._parse_webhook_payload(
payload
)
return {
"version": version,
"payload": normalized_payload,
"raw_payload": raw_payload,
}
if not verify_secret:
raise exceptions.ValidationError(
"Cannot verify webhook: `verify_secret` was provided but is empty — "
"your COMPOSIO_WEBHOOK_SECRET is likely unset. Set the secret, or omit "
"`verify_secret` entirely to parse without verification."
)
# Secret provided: signature headers are required to verify.
webhook_id = self._get_header(raw_headers, "webhook-id")
timestamp = self._get_header(raw_headers, "webhook-timestamp")
signature = self._get_header(raw_headers, "webhook-signature")
missing = [
name
for name, value in (
("webhook-id", webhook_id),
("webhook-timestamp", timestamp),
("webhook-signature", signature),
)
if not value
]
if missing:
raise exceptions.ValidationError(
"Cannot verify webhook: missing signature header(s) "
f"{', '.join(repr(name) for name in missing)}. "
"Pass the raw, unparsed request body and ensure the Composio "
"signature headers (webhook-id, webhook-timestamp, "
"webhook-signature) are forwarded to triggers.parse(). "
"To parse without verifying, omit `verify_secret`."
)
return self.verify_webhook(
id=t.cast(str, webhook_id),
payload=payload,
secret=verify_secret,
signature=t.cast(str, signature),
timestamp=t.cast(str, timestamp),
tolerance=tolerance,
)
@staticmethod
def _extract_request_body(request: t.Any) -> t.Union[str, bytes, None]:
"""Read the raw body from a framework request object.
Tries the common attributes used by Flask (``get_data``/``data``),
Django (``body``) and similar. Returns ``None`` when nothing usable is
found so the caller surfaces a clear parse error.
"""
if request is None:
return None
# Flask: request.get_data() returns the raw body bytes.
get_data = getattr(request, "get_data", None)
if callable(get_data):
return get_data()
for attr in ("body", "data"):
value = getattr(request, attr, None)
if value is not None and not callable(value):
return value
return None
@staticmethod
def _body_to_str(body: t.Union[str, bytes, t.Mapping[str, t.Any], None]) -> str:
"""Coerce a body (str, bytes, parsed mapping, or None) into a raw string."""
if body is None:
return ""
if isinstance(body, str):
return body
if isinstance(body, (bytes, bytearray)):
return bytes(body).decode("utf-8")
# Already-parsed mapping (e.g. a framework that pre-parsed JSON). Note:
# re-serializing cannot reproduce the exact signed bytes, so signature
# verification on a pre-parsed body is best-effort only.
return json.dumps(body)
@staticmethod
def _get_header(headers: t.Any, name: str) -> t.Optional[str]:
"""Read a header value case-insensitively from a headers mapping.
Supports both plain ``dict`` headers and framework header objects
(Werkzeug/Django ``HttpHeaders``) that expose a ``get`` method, which is
already case-insensitive.
"""
if headers is None:
return None
get = getattr(headers, "get", None)
if callable(get):
value = get(name)
if value is not None:
return str(value)
# Fall back to a manual case-insensitive scan for plain mappings.
try:
items = headers.items()
except AttributeError:
return None
target = name.lower()
for key, value in items:
if isinstance(key, str) and key.lower() == target:
return str(value) if value is not None else None
return None
def _verify_webhook_signature(
self,
*,
webhook_id: str,
webhook_timestamp: str,
payload: str,
signature: str,
secret: str,
) -> None:
"""
Verify the HMAC-SHA256 signature of a webhook payload.
The signature is computed as: HMAC-SHA256(webhookId.webhookTimestamp.payload, secret)
and then base64 encoded with a 'v1,' prefix.
:param webhook_id: The webhook message ID from header
:param webhook_timestamp: The webhook timestamp from header
:param payload: The raw webhook payload
:param signature: The signature to verify (format: 'v1,base64EncodedSignature')
:param secret: The webhook secret
:raises WebhookSignatureVerificationError: If verification fails
"""
if not payload:
raise exceptions.WebhookSignatureVerificationError(
"No webhook payload was provided."
)
if not signature:
raise exceptions.WebhookSignatureVerificationError(
"No signature header value was provided. "
"Please pass the value of the webhook signature header."
)
if not secret:
raise exceptions.WebhookSignatureVerificationError(
"No webhook secret was provided. "
"You can find your webhook secret in your Composio dashboard."
)
if not webhook_id:
raise exceptions.WebhookSignatureVerificationError(
"No webhook ID was provided. "
"Please pass the value of the 'webhook-id' header."
)
if not webhook_timestamp:
raise exceptions.WebhookSignatureVerificationError(
"No webhook timestamp was provided. "
"Please pass the value of the 'webhook-timestamp' header."
)
# Parse signature header - format is "v1,base64Sig" or "v1,sig1 v1,sig2"
# Split by space to handle multiple signatures
signature_parts = signature.split(" ")
v1_signatures: t.List[str] = []
for part in signature_parts:
if part.startswith("v1,"):
v1_signatures.append(part[3:]) # Remove "v1," prefix
if not v1_signatures:
raise exceptions.WebhookSignatureVerificationError(
"No valid v1 signature found in the signature header. "
"Expected format: 'v1,base64EncodedSignature'"
)
# Construct the string to sign: webhookId.webhookTimestamp.payload
to_sign = f"{webhook_id}.{webhook_timestamp}.{payload}"
# Compute expected signature
expected_signature_bytes = hmac.new(
key=secret.encode("utf-8"),
msg=to_sign.encode("utf-8"),
digestmod=hashlib.sha256,
).digest()
expected_signature_b64 = base64.b64encode(expected_signature_bytes).decode(
"utf-8"
)
# Check if any of the provided signatures match (timing-safe)
for provided_sig in v1_signatures:
try:
if hmac.compare_digest(provided_sig, expected_signature_b64):
return # Signature is valid
except Exception:
continue # Invalid signature format, try next
raise exceptions.WebhookSignatureVerificationError(
"The signature provided is invalid."
)
def _validate_webhook_timestamp_header(
self, timestamp: str, tolerance: int
) -> None:
"""
Validate that the webhook timestamp header is within the allowed tolerance.
:param timestamp: The webhook timestamp from header (Unix seconds)
:param tolerance: Maximum allowed age in seconds
:raises WebhookSignatureVerificationError: If timestamp is outside tolerance
:raises WebhookPayloadError: If timestamp format is invalid
"""
try:
timestamp_seconds = int(timestamp)
except (ValueError, TypeError) as e:
raise exceptions.WebhookPayloadError(
f"Invalid webhook timestamp: {timestamp}. "
"Expected Unix timestamp in seconds."
) from e
current_time = int(time.time())
time_difference = abs(current_time - timestamp_seconds)
if time_difference > tolerance:
raise exceptions.WebhookSignatureVerificationError(
f"The webhook timestamp is outside the allowed tolerance. "
f"The webhook was sent {time_difference} seconds ago, "
f"but the maximum allowed age is {tolerance} seconds."
)
def _parse_webhook_payload(
self, payload: str
) -> t.Tuple[WebhookVersion, WebhookPayload, TriggerEvent]:
"""
Parse webhook payload and detect version.
:param payload: The raw webhook payload string
:return: Tuple of (version, raw_payload, normalized_payload)
:raises WebhookPayloadError: If payload cannot be parsed
"""
try:
data = json.loads(payload)
except json.JSONDecodeError as e:
raise exceptions.WebhookPayloadError(
f"Failed to parse webhook payload as JSON: {e}"
) from e
# Try V3 first — same envelope the realtime channel uses, so reuse the
# shared detector/normalizer to keep the two paths from drifting.
if _is_v3_envelope(data):
return (
WebhookVersion.V3,
t.cast(WebhookPayloadV3, data),
_build_trigger_event_from_v3(t.cast(WebhookPayloadV3, data)),
)
# Try V2 (has 'type', 'timestamp', 'data' with nested fields)
if (
isinstance(data, dict)
and "type" in data
and "timestamp" in data
and "data" in data
and isinstance(data.get("data"), dict)
and "connection_id" in data.get("data", {})
):
return (
WebhookVersion.V2,
t.cast(WebhookPayloadV2, data),
self._normalize_v2_payload(t.cast(WebhookPayloadV2, data)),
)
# Try V1 (has 'trigger_name', 'connection_id', 'trigger_id', 'payload')
if (
isinstance(data, dict)
and "trigger_name" in data
and "connection_id" in data
and "trigger_id" in data
and "payload" in data
):
return (
WebhookVersion.V1,
t.cast(WebhookPayloadV1, data),
self._normalize_v1_payload(t.cast(WebhookPayloadV1, data)),
)
raise exceptions.WebhookPayloadError(
"Webhook payload does not match any known version (V1, V2, or V3). "
"Please ensure the payload structure is correct."
)
def _normalize_v1_payload(self, data: WebhookPayloadV1) -> TriggerEvent:
"""Normalize V1 payload to TriggerEvent format."""
return t.cast(
TriggerEvent,
{
"id": data["trigger_id"],
"uuid": data["trigger_id"],
"user_id": "", # V1 doesn't have user_id
"toolkit_slug": "", # V1 doesn't have toolkit_slug
"trigger_slug": data["trigger_name"],
"metadata": {
"id": data["trigger_id"],
"uuid": data["trigger_id"],
"toolkit_slug": "",
"trigger_slug": data["trigger_name"],
"trigger_data": None,
"trigger_config": {},
"connected_account": {
"id": data["connection_id"],
"uuid": data["connection_id"],
"auth_config_id": "",
"auth_config_uuid": "",
"user_id": "",
"status": "ACTIVE",
},
},
"payload": data["payload"],
"original_payload": None,
},
)
def _normalize_v2_payload(self, data: WebhookPayloadV2) -> TriggerEvent:
"""Normalize V2 payload to TriggerEvent format."""
payload_data = data["data"]
return t.cast(
TriggerEvent,
{
"id": payload_data.get(
"trigger_nano_id", payload_data.get("trigger_id", "")
),
"uuid": payload_data.get("trigger_id", ""),
"user_id": payload_data.get("user_id", ""),
"toolkit_slug": data["type"].upper() if data.get("type") else "",
"trigger_slug": data["type"].upper() if data.get("type") else "",
"metadata": {
"id": payload_data.get("trigger_nano_id", ""),
"uuid": payload_data.get("trigger_id", ""),
"toolkit_slug": data["type"].upper() if data.get("type") else "",
"trigger_slug": data["type"].upper() if data.get("type") else "",
"trigger_data": None,
"trigger_config": {},
"connected_account": {
"id": payload_data.get("connection_nano_id", ""),
"uuid": payload_data.get("connection_id", ""),
"auth_config_id": "",
"auth_config_uuid": "",
"user_id": payload_data.get("user_id", ""),
"status": "ACTIVE",
},
},
"payload": {
k: v
for k, v in payload_data.items()
if k
not in (
"connection_id",
"connection_nano_id",
"trigger_nano_id",
"trigger_id",
"user_id",
)
},
"original_payload": None,
},
)