Skip to content

Commit ec886cb

Browse files
Add webhook management and event parsing
1 parent 343d5d3 commit ec886cb

9 files changed

Lines changed: 287 additions & 3 deletions

File tree

linkedapi/__init__.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
AdminHttpClient,
44
AdminLimits,
55
AdminSubscription,
6+
AdminWebhooks,
67
LinkedApiAdmin,
78
)
89
from linkedapi.client import LinkedApi
@@ -64,8 +65,9 @@
6465
)
6566
from linkedapi.types import * # noqa: F403
6667
from linkedapi.types import __all__ as _types_all
68+
from linkedapi.webhooks import parse_webhook_event
6769

68-
__version__ = "1.0.4"
70+
__version__ = "1.1.0"
6971
PredefinedOperation = Operation
7072

7173
__all__ = [
@@ -74,6 +76,7 @@
7476
"AdminHttpClient",
7577
"AdminLimits",
7678
"AdminSubscription",
79+
"AdminWebhooks",
7780
"ActionConfig",
7881
"ArrayWorkflowMapper",
7982
"BaseMapper",
@@ -129,5 +132,6 @@
129132
"VoidWorkflowMapper",
130133
"WithdrawConnectionRequest",
131134
"__version__",
135+
"parse_webhook_event",
132136
"poll_workflow_result",
133137
]

linkedapi/admin/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@
33
from linkedapi.admin.http_client import AdminHttpClient
44
from linkedapi.admin.limits import AdminLimits
55
from linkedapi.admin.subscription import AdminSubscription
6+
from linkedapi.admin.webhooks import AdminWebhooks
67

78
__all__ = [
89
"AdminAccounts",
910
"AdminHttpClient",
1011
"AdminLimits",
1112
"AdminSubscription",
13+
"AdminWebhooks",
1214
"LinkedApiAdmin",
1315
]

linkedapi/admin/admin.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,13 @@
66
from linkedapi.admin.http_client import AdminHttpClient
77
from linkedapi.admin.limits import AdminLimits
88
from linkedapi.admin.subscription import AdminSubscription
9+
from linkedapi.admin.webhooks import AdminWebhooks
910
from linkedapi.http import HttpClient
1011
from linkedapi.types.admin import AdminConfig
1112

1213

1314
class LinkedApiAdmin:
14-
"""Admin SDK for Linked API subscription, account, and limit management."""
15+
"""Admin SDK for Linked API subscription, account, limit, and webhook management."""
1516

1617
def __init__(self, config: AdminConfig | HttpClient[Any]) -> None:
1718
http_client = (
@@ -20,3 +21,4 @@ def __init__(self, config: AdminConfig | HttpClient[Any]) -> None:
2021
self.subscription = AdminSubscription(http_client)
2122
self.accounts = AdminAccounts(http_client)
2223
self.limits = AdminLimits(http_client)
24+
self.webhooks = AdminWebhooks(http_client)

linkedapi/admin/webhooks.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
from __future__ import annotations
2+
3+
from typing import Any
4+
5+
from linkedapi.errors import LinkedApiError
6+
from linkedapi.http import HttpClient
7+
from linkedapi.types.webhooks import (
8+
DeleteWebhookParams,
9+
ReplayWebhookDeliveryParams,
10+
SetWebhookParams,
11+
SetWebhookPayloadModeParams,
12+
WebhookDelivery,
13+
WebhookSubscription,
14+
)
15+
16+
17+
class AdminWebhooks:
18+
"""Manage the client's registered outbound webhook and inspect recent deliveries.
19+
20+
A client may hold at most one active webhook. It receives every event Linked API emits
21+
(workflow lifecycle + account status changes); filter by the event ``type`` on your receiver.
22+
"""
23+
24+
def __init__(self, http_client: HttpClient[Any]) -> None:
25+
self.http_client = http_client
26+
27+
def set(self, params: SetWebhookParams) -> WebhookSubscription:
28+
result = self._post_result("/admin/webhook.set", "Failed to set webhook", params)
29+
return WebhookSubscription.model_validate(result["webhook"])
30+
31+
def get(self) -> list[WebhookSubscription]:
32+
result = self._post_result("/admin/webhook.get", "Failed to get webhooks")
33+
return [WebhookSubscription.model_validate(item) for item in result["webhooks"]]
34+
35+
def set_payload_mode(self, params: SetWebhookPayloadModeParams) -> WebhookSubscription:
36+
result = self._post_result(
37+
"/admin/webhook.setPayloadMode",
38+
"Failed to set webhook payload mode",
39+
params,
40+
)
41+
return WebhookSubscription.model_validate(result["webhook"])
42+
43+
def delete(self, params: DeleteWebhookParams) -> None:
44+
self._post_void("/admin/webhook.delete", "Failed to delete webhook", params)
45+
46+
def deliveries(self) -> list[WebhookDelivery]:
47+
result = self._post_result("/admin/webhook.deliveries", "Failed to get webhook deliveries")
48+
return [WebhookDelivery.model_validate(item) for item in result["deliveries"]]
49+
50+
def replay_delivery(self, params: ReplayWebhookDeliveryParams) -> None:
51+
self._post_void(
52+
"/admin/webhook.replayDelivery",
53+
"Failed to replay webhook delivery",
54+
params,
55+
)
56+
57+
def send_test(self) -> None:
58+
self._post_void("/admin/webhook.sendTest", "Failed to send test webhook")
59+
60+
def _post_result(
61+
self,
62+
path: str,
63+
default_message: str,
64+
params: Any | None = None,
65+
) -> dict[str, Any]:
66+
response = self.http_client.post(path, params)
67+
if response.success and response.result is not None:
68+
result: dict[str, Any] = response.result
69+
return result
70+
raise LinkedApiError(
71+
response.error.type if response.error else "httpError",
72+
response.error.message if response.error else default_message,
73+
)
74+
75+
def _post_void(self, path: str, default_message: str, params: Any | None = None) -> None:
76+
response = self.http_client.post(path, params)
77+
if response.success:
78+
return
79+
raise LinkedApiError(
80+
response.error.type if response.error else "httpError",
81+
response.error.message if response.error else default_message,
82+
)

linkedapi/types/__init__.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,26 @@
178178
RetrievePerformanceResult,
179179
RetrieveSSIResult,
180180
)
181+
from linkedapi.types.webhooks import (
182+
AccountWebhookEvent,
183+
AccountWebhookEventData,
184+
AccountWebhookStatus,
185+
DeleteWebhookParams,
186+
ReplayWebhookDeliveryParams,
187+
SetWebhookParams,
188+
SetWebhookPayloadModeParams,
189+
WebhookDelivery,
190+
WebhookDeliveryStatus,
191+
WebhookEvent,
192+
WebhookEventType,
193+
WebhookPayloadMode,
194+
WebhookSubscription,
195+
WebhookTestEvent,
196+
WebhookTestEventData,
197+
WorkflowWebhookEvent,
198+
WorkflowWebhookEventData,
199+
WorkflowWebhookStatus,
200+
)
181201
from linkedapi.types.workflow import (
182202
LinkedApiActionError,
183203
WorkflowCancelResponse,
@@ -194,6 +214,9 @@
194214

195215
__all__ = [
196216
"AccountInfo",
217+
"AccountWebhookEvent",
218+
"AccountWebhookEventData",
219+
"AccountWebhookStatus",
197220
"AccountsResult",
198221
"AdminAccount",
199222
"AdminAccountStatus",
@@ -232,6 +255,7 @@
232255
"CreateReconnectionSessionResult",
233256
"DeleteLimitEntry",
234257
"DeleteLimitsParams",
258+
"DeleteWebhookParams",
235259
"DisconnectParams",
236260
"EmploymentType",
237261
"FetchCompanyParams",
@@ -315,6 +339,7 @@
315339
"RemoveConnectionParams",
316340
"ReparseAccountInfoParams",
317341
"ReparseAccountInfoResult",
342+
"ReplayWebhookDeliveryParams",
318343
"ResetLimitsParams",
319344
"RetrieveConnectionsFilter",
320345
"RetrieveConnectionsParams",
@@ -341,6 +366,8 @@
341366
"SetSeatsParams",
342367
"SetSeatsResult",
343368
"SetSeatsStatus",
369+
"SetWebhookParams",
370+
"SetWebhookPayloadModeParams",
344371
"StCompanyDm",
345372
"StCompanyEmployee",
346373
"StCompanyEmployeesFilter",
@@ -350,6 +377,14 @@
350377
"SubscriptionStatus",
351378
"SubscriptionStatusValue",
352379
"SyncConversationParams",
380+
"WebhookDelivery",
381+
"WebhookDeliveryStatus",
382+
"WebhookEvent",
383+
"WebhookEventType",
384+
"WebhookPayloadMode",
385+
"WebhookSubscription",
386+
"WebhookTestEvent",
387+
"WebhookTestEventData",
353388
"WithdrawConnectionRequestParams",
354389
"WorkflowCancelResponse",
355390
"WorkflowCompletion",
@@ -361,6 +396,9 @@
361396
"WorkflowResponse",
362397
"WorkflowStartedResponse",
363398
"WorkflowStatus",
399+
"WorkflowWebhookEvent",
400+
"WorkflowWebhookEventData",
401+
"WorkflowWebhookStatus",
364402
"YearsOfExperience",
365403
"dump_model_by_name",
366404
"serialize_model",

linkedapi/types/webhooks.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
from __future__ import annotations
2+
3+
from typing import Any, Literal
4+
5+
from linkedapi.types.base import LinkedApiModel
6+
7+
WebhookPayloadMode = Literal["thin", "fat"]
8+
WebhookEventType = Literal[
9+
"workflow.created",
10+
"workflow.started",
11+
"workflow.completed",
12+
"account.active",
13+
"account.reconnectionRequired",
14+
"account.frozen",
15+
"account.deleted",
16+
"webhook.test",
17+
]
18+
WebhookDeliveryStatus = Literal["pending", "delivering", "success", "failed"]
19+
WorkflowWebhookStatus = Literal["pending", "running", "completed", "failed"]
20+
AccountWebhookStatus = Literal["active", "reconnection_required", "frozen", "deleted"]
21+
22+
23+
class WebhookSubscription(LinkedApiModel):
24+
id: str | None = None
25+
url: str | None = None
26+
payload_mode: WebhookPayloadMode | None = None
27+
is_active: bool | None = None
28+
created_at: str | None = None
29+
30+
31+
class WebhookDelivery(LinkedApiModel):
32+
id: str | None = None
33+
event_type: WebhookEventType | None = None
34+
event_id: str | None = None
35+
status: WebhookDeliveryStatus | None = None
36+
attempts: int | None = None
37+
response_status_code: int | None = None
38+
last_error: str | None = None
39+
created_at: str | None = None
40+
updated_at: str | None = None
41+
42+
43+
class SetWebhookParams(LinkedApiModel):
44+
url: str
45+
payload_mode: WebhookPayloadMode | None = None
46+
47+
48+
class SetWebhookPayloadModeParams(LinkedApiModel):
49+
id: str
50+
payload_mode: WebhookPayloadMode
51+
52+
53+
class DeleteWebhookParams(LinkedApiModel):
54+
id: str
55+
56+
57+
class ReplayWebhookDeliveryParams(LinkedApiModel):
58+
delivery_id: str
59+
60+
61+
class WorkflowWebhookEventData(LinkedApiModel):
62+
workflow_id: str | None = None
63+
account_id: str | None = None
64+
status: WorkflowWebhookStatus | None = None
65+
# Present only on workflow.completed delivered in `fat` payload mode; in `thin` mode fetch the
66+
# result via the workflow API by workflow_id.
67+
result: Any | None = None
68+
69+
70+
class WorkflowWebhookEvent(LinkedApiModel):
71+
id: str
72+
type: Literal["workflow.created", "workflow.started", "workflow.completed"]
73+
created_at: str | None = None
74+
data: WorkflowWebhookEventData
75+
76+
77+
class AccountWebhookEventData(LinkedApiModel):
78+
account_id: str | None = None
79+
status: AccountWebhookStatus | None = None
80+
81+
82+
class AccountWebhookEvent(LinkedApiModel):
83+
id: str
84+
type: Literal[
85+
"account.active",
86+
"account.reconnectionRequired",
87+
"account.frozen",
88+
"account.deleted",
89+
]
90+
created_at: str | None = None
91+
data: AccountWebhookEventData
92+
93+
94+
class WebhookTestEventData(LinkedApiModel):
95+
message: str | None = None
96+
97+
98+
class WebhookTestEvent(LinkedApiModel):
99+
id: str
100+
type: Literal["webhook.test"]
101+
created_at: str | None = None
102+
data: WebhookTestEventData
103+
104+
105+
WebhookEvent = WorkflowWebhookEvent | AccountWebhookEvent | WebhookTestEvent

linkedapi/webhooks/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from linkedapi.webhooks.parse import parse_webhook_event
2+
3+
__all__ = ["parse_webhook_event"]

linkedapi/webhooks/parse.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
from __future__ import annotations
2+
3+
import json
4+
5+
from linkedapi.types.webhooks import (
6+
AccountWebhookEvent,
7+
WebhookEvent,
8+
WebhookTestEvent,
9+
WorkflowWebhookEvent,
10+
)
11+
12+
13+
def parse_webhook_event(raw_body: str | bytes) -> WebhookEvent:
14+
"""Parse a raw webhook request body into a typed Linked API event.
15+
16+
Pass the raw HTTP body (``str`` or ``bytes``) exactly as received, then branch on the
17+
returned event's ``type``.
18+
19+
Raises:
20+
ValueError: when the body is not valid JSON or is missing the ``id`` / ``type`` fields,
21+
or carries an unknown event type.
22+
"""
23+
text = raw_body.decode("utf-8") if isinstance(raw_body, (bytes, bytearray)) else raw_body
24+
25+
try:
26+
parsed = json.loads(text)
27+
except (ValueError, TypeError) as error:
28+
msg = "Invalid webhook payload: body is not valid JSON"
29+
raise ValueError(msg) from error
30+
31+
if (
32+
not isinstance(parsed, dict)
33+
or not isinstance(parsed.get("id"), str)
34+
or not isinstance(parsed.get("type"), str)
35+
):
36+
msg = 'Invalid webhook payload: missing "id" or "type"'
37+
raise ValueError(msg)
38+
39+
event_type: str = parsed["type"]
40+
if event_type.startswith("workflow."):
41+
return WorkflowWebhookEvent.model_validate(parsed)
42+
if event_type.startswith("account."):
43+
return AccountWebhookEvent.model_validate(parsed)
44+
if event_type == "webhook.test":
45+
return WebhookTestEvent.model_validate(parsed)
46+
47+
msg = f"Unknown webhook event type: {event_type}"
48+
raise ValueError(msg)

0 commit comments

Comments
 (0)