msaas-webhooks 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,17 @@
1
+ Metadata-Version: 2.4
2
+ Name: msaas-webhooks
3
+ Version: 0.1.0
4
+ Summary: Webhook delivery system with HMAC signing, retries, and FastAPI router
5
+ License: MIT
6
+ Requires-Python: >=3.12
7
+ Requires-Dist: asyncpg>=0.30.0
8
+ Requires-Dist: fastapi>=0.115.0
9
+ Requires-Dist: httpx>=0.27.0
10
+ Requires-Dist: msaas-api-core
11
+ Requires-Dist: msaas-errors
12
+ Requires-Dist: pydantic>=2.0
13
+ Provides-Extra: dev
14
+ Requires-Dist: pytest-asyncio>=0.24.0; extra == 'dev'
15
+ Requires-Dist: pytest>=8.0; extra == 'dev'
16
+ Requires-Dist: respx>=0.22.0; extra == 'dev'
17
+ Requires-Dist: time-machine>=2.13.0; extra == 'dev'
@@ -0,0 +1,12 @@
1
+ webhooks/__init__.py,sha256=DanYCmP0-W27fyE0b9S4auo2hfKKIwBHf2A7TmHe_sc,766
2
+ webhooks/config.py,sha256=OYAsRyCtJJ85jshHh8UuJeiPqlOOa2atELZxMFoTGoU,1436
3
+ webhooks/delivery.py,sha256=QISa6DnfoYDwU2j_wG_KnNPo08-NofKAtIynge_vqIY,6613
4
+ webhooks/models.py,sha256=ZAwuTGUrcjhCX4xxIzEdkN_JmVdEQo9mmPCdaA9Yf74,2367
5
+ webhooks/router.py,sha256=V3xh6Z8zRQSIa3PuFliurb3eK2GholjVPK8buE6GtgQ,6181
6
+ webhooks/signing.py,sha256=edW0sdKJnYCy-913bFyalW2naQSCBqr8KVCb4ex_zUU,1895
7
+ webhooks/store.py,sha256=lCWKiTwwz9lOnQ4DCe97M3AmXzjdFCMta1C6AzWhFm0,2721
8
+ webhooks/store_deliveries.py,sha256=nr3UoYOdFH_NrC6D8F1ykNmIMK3B_ZbE5tBK9ugKedQ,5538
9
+ webhooks/store_endpoints.py,sha256=o77wsKG1uo3EjJl9aBpSiZ2Oop8D6TGOV_GigjZgU_w,4357
10
+ msaas_webhooks-0.1.0.dist-info/METADATA,sha256=9foTo-6BfhSHDucj3ftxhEzgxobluC8qd2N7Fr0ilLY,568
11
+ msaas_webhooks-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
12
+ msaas_webhooks-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
webhooks/__init__.py ADDED
@@ -0,0 +1,28 @@
1
+ """Willian Webhooks -- Webhook delivery system with HMAC signing and retries."""
2
+
3
+ from webhooks.config import WebhookConfig, init_webhooks
4
+ from webhooks.delivery import deliver_webhook, dispatch_event, retry_failed_deliveries
5
+ from webhooks.models import (
6
+ DeliveryStatus,
7
+ WebhookDelivery,
8
+ WebhookEndpoint,
9
+ WebhookEvent,
10
+ )
11
+ from webhooks.router import WebhookRouter
12
+ from webhooks.signing import generate_secret, sign_payload, verify_signature
13
+
14
+ __all__ = [
15
+ "DeliveryStatus",
16
+ "WebhookConfig",
17
+ "WebhookDelivery",
18
+ "WebhookEndpoint",
19
+ "WebhookEvent",
20
+ "WebhookRouter",
21
+ "deliver_webhook",
22
+ "dispatch_event",
23
+ "generate_secret",
24
+ "init_webhooks",
25
+ "retry_failed_deliveries",
26
+ "sign_payload",
27
+ "verify_signature",
28
+ ]
webhooks/config.py ADDED
@@ -0,0 +1,52 @@
1
+ """Webhook module configuration and initialization."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncpg
6
+ from pydantic import BaseModel
7
+
8
+ _config: WebhookConfig | None = None
9
+ _pool: asyncpg.Pool | None = None
10
+
11
+
12
+ class WebhookConfig(BaseModel):
13
+ """Configuration for the webhook module."""
14
+
15
+ database_url: str
16
+ max_retries: int = 5
17
+ retry_delay_seconds: int = 30
18
+ timeout_seconds: int = 10
19
+ signing_secret_prefix: str = "whsec_"
20
+
21
+
22
+ async def init_webhooks(config: WebhookConfig) -> None:
23
+ """Initialize the webhook module with the given configuration.
24
+
25
+ Creates an asyncpg connection pool and stores the config globally.
26
+ Must be called before any store or delivery operations.
27
+ """
28
+ global _config, _pool
29
+ _config = config
30
+ _pool = await asyncpg.create_pool(dsn=config.database_url, min_size=2, max_size=10)
31
+
32
+
33
+ def get_config() -> WebhookConfig:
34
+ """Return the current module configuration.
35
+
36
+ Raises:
37
+ RuntimeError: If init_webhooks() has not been called.
38
+ """
39
+ if _config is None:
40
+ raise RuntimeError("Webhook module not initialized. Call init_webhooks() first.")
41
+ return _config
42
+
43
+
44
+ def get_pool() -> asyncpg.Pool:
45
+ """Return the current connection pool.
46
+
47
+ Raises:
48
+ RuntimeError: If init_webhooks() has not been called.
49
+ """
50
+ if _pool is None:
51
+ raise RuntimeError("Webhook module not initialized. Call init_webhooks() first.")
52
+ return _pool
webhooks/delivery.py ADDED
@@ -0,0 +1,213 @@
1
+ """Webhook delivery engine with retry support."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import time
8
+ from datetime import UTC, datetime
9
+
10
+ import httpx
11
+
12
+ from webhooks.config import get_config
13
+ from webhooks.models import (
14
+ DeliveryStatus,
15
+ WebhookDelivery,
16
+ WebhookEndpoint,
17
+ WebhookEvent,
18
+ )
19
+ from webhooks.signing import sign_payload
20
+ from webhooks.store import (
21
+ get_delivery,
22
+ get_endpoint,
23
+ get_failed_deliveries,
24
+ list_endpoints,
25
+ record_delivery,
26
+ update_delivery,
27
+ )
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ async def deliver_webhook(
33
+ endpoint: WebhookEndpoint,
34
+ event: WebhookEvent,
35
+ ) -> WebhookDelivery:
36
+ """Send a single webhook HTTP POST to the endpoint.
37
+
38
+ Creates a delivery record, sends the request with signed headers,
39
+ and updates the delivery with the result.
40
+
41
+ Args:
42
+ endpoint: The target webhook endpoint.
43
+ event: The event to deliver.
44
+
45
+ Returns:
46
+ The updated WebhookDelivery record.
47
+ """
48
+ config = get_config()
49
+ payload_str = json.dumps(event.payload, default=str, sort_keys=True)
50
+ timestamp = int(time.time())
51
+ signature = sign_payload(payload_str, endpoint.secret, timestamp)
52
+
53
+ delivery = await record_delivery(
54
+ endpoint_id=endpoint.id,
55
+ event_type=event.event_type,
56
+ payload=event.payload,
57
+ )
58
+
59
+ headers = {
60
+ "Content-Type": "application/json",
61
+ "X-Webhook-ID": str(delivery.id),
62
+ "X-Webhook-Timestamp": str(timestamp),
63
+ "X-Webhook-Signature": signature,
64
+ }
65
+
66
+ try:
67
+ async with httpx.AsyncClient(timeout=config.timeout_seconds) as client:
68
+ response = await client.post(
69
+ endpoint.url,
70
+ content=payload_str,
71
+ headers=headers,
72
+ )
73
+
74
+ is_success = 200 <= response.status_code < 300
75
+ status = DeliveryStatus.SUCCESS if is_success else DeliveryStatus.FAILED
76
+ response_body = response.text[:2000] if response.text else None
77
+
78
+ await update_delivery(
79
+ delivery.id,
80
+ status=status,
81
+ attempts=1,
82
+ response_status=response.status_code,
83
+ response_body=response_body,
84
+ )
85
+
86
+ if is_success:
87
+ logger.info(
88
+ "Webhook delivered successfully",
89
+ extra={"delivery_id": str(delivery.id), "endpoint_url": endpoint.url},
90
+ )
91
+ else:
92
+ logger.warning(
93
+ "Webhook delivery failed with status %d",
94
+ response.status_code,
95
+ extra={"delivery_id": str(delivery.id), "endpoint_url": endpoint.url},
96
+ )
97
+
98
+ except httpx.HTTPError as exc:
99
+ logger.error(
100
+ "Webhook delivery error: %s",
101
+ str(exc),
102
+ extra={"delivery_id": str(delivery.id), "endpoint_url": endpoint.url},
103
+ )
104
+ await update_delivery(
105
+ delivery.id,
106
+ status=DeliveryStatus.FAILED,
107
+ attempts=1,
108
+ response_body=str(exc)[:2000],
109
+ )
110
+
111
+ # Re-fetch to return the updated state
112
+ updated = await get_delivery(delivery.id)
113
+ return updated or delivery
114
+
115
+
116
+ async def retry_failed_deliveries() -> list[WebhookDelivery]:
117
+ """Retry all failed deliveries that haven't exceeded max attempts.
118
+
119
+ Uses exponential backoff: only retries deliveries whose last attempt
120
+ was at least ``retry_delay_seconds * 2^(attempts-1)`` seconds ago.
121
+
122
+ Returns:
123
+ List of delivery records that were retried.
124
+ """
125
+ config = get_config()
126
+ failed = await get_failed_deliveries(max_attempts=config.max_retries)
127
+ retried: list[WebhookDelivery] = []
128
+
129
+ now = datetime.now(UTC)
130
+
131
+ for delivery in failed:
132
+ # Exponential backoff check
133
+ if delivery.last_attempt_at is not None:
134
+ backoff = config.retry_delay_seconds * (2 ** (delivery.attempts - 1))
135
+ elapsed = (now - delivery.last_attempt_at).total_seconds()
136
+ if elapsed < backoff:
137
+ continue
138
+
139
+ # Fetch the endpoint to get its URL and secret
140
+ endpoint = await get_endpoint(delivery.endpoint_id)
141
+ if endpoint is None or not endpoint.active:
142
+ continue
143
+
144
+ payload_str = json.dumps(delivery.payload, default=str, sort_keys=True)
145
+ timestamp = int(time.time())
146
+ signature = sign_payload(payload_str, endpoint.secret, timestamp)
147
+
148
+ headers = {
149
+ "Content-Type": "application/json",
150
+ "X-Webhook-ID": str(delivery.id),
151
+ "X-Webhook-Timestamp": str(timestamp),
152
+ "X-Webhook-Signature": signature,
153
+ }
154
+
155
+ try:
156
+ async with httpx.AsyncClient(timeout=config.timeout_seconds) as client:
157
+ response = await client.post(
158
+ endpoint.url,
159
+ content=payload_str,
160
+ headers=headers,
161
+ )
162
+
163
+ is_success = 200 <= response.status_code < 300
164
+ status = DeliveryStatus.SUCCESS if is_success else DeliveryStatus.FAILED
165
+
166
+ await update_delivery(
167
+ delivery.id,
168
+ status=status,
169
+ attempts=delivery.attempts + 1,
170
+ response_status=response.status_code,
171
+ response_body=(response.text[:2000] if response.text else None),
172
+ )
173
+
174
+ except httpx.HTTPError as exc:
175
+ await update_delivery(
176
+ delivery.id,
177
+ status=DeliveryStatus.FAILED,
178
+ attempts=delivery.attempts + 1,
179
+ response_body=str(exc)[:2000],
180
+ )
181
+
182
+ updated = await get_delivery(delivery.id)
183
+ if updated:
184
+ retried.append(updated)
185
+
186
+ return retried
187
+
188
+
189
+ async def dispatch_event(event_type: str, payload: dict) -> list[WebhookDelivery]:
190
+ """Dispatch an event to all active endpoints subscribed to it.
191
+
192
+ Args:
193
+ event_type: The event type string (e.g. ``"order.created"``).
194
+ payload: The event payload dictionary.
195
+
196
+ Returns:
197
+ List of delivery records for each endpoint that was notified.
198
+ """
199
+ endpoints = await list_endpoints(active_only=True)
200
+ event = WebhookEvent(
201
+ event_type=event_type,
202
+ payload=payload,
203
+ timestamp=datetime.now(UTC),
204
+ )
205
+
206
+ deliveries: list[WebhookDelivery] = []
207
+ for endpoint in endpoints:
208
+ # Check if this endpoint subscribes to the event type or uses wildcard
209
+ if "*" in endpoint.events or event_type in endpoint.events:
210
+ delivery = await deliver_webhook(endpoint, event)
211
+ deliveries.append(delivery)
212
+
213
+ return deliveries
webhooks/models.py ADDED
@@ -0,0 +1,99 @@
1
+ """Pydantic models for the webhook module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import enum
6
+ from datetime import datetime
7
+ from typing import Any
8
+ from uuid import UUID
9
+
10
+ from pydantic import BaseModel, Field
11
+
12
+
13
+ class DeliveryStatus(str, enum.Enum):
14
+ """Status of a webhook delivery attempt."""
15
+
16
+ PENDING = "pending"
17
+ SUCCESS = "success"
18
+ FAILED = "failed"
19
+
20
+
21
+ class WebhookEndpoint(BaseModel):
22
+ """A registered webhook endpoint."""
23
+
24
+ id: UUID
25
+ url: str
26
+ events: list[str] = Field(description="Subscribed event types")
27
+ secret: str = Field(description="HMAC signing key")
28
+ active: bool = True
29
+ created_at: datetime
30
+ metadata: dict[str, Any] = Field(default_factory=dict)
31
+
32
+
33
+ class WebhookDelivery(BaseModel):
34
+ """Record of a webhook delivery attempt."""
35
+
36
+ id: UUID
37
+ endpoint_id: UUID
38
+ event_type: str
39
+ payload: dict[str, Any]
40
+ status: DeliveryStatus = DeliveryStatus.PENDING
41
+ attempts: int = 0
42
+ last_attempt_at: datetime | None = None
43
+ response_status: int | None = None
44
+ response_body: str | None = None
45
+ created_at: datetime
46
+
47
+
48
+ class WebhookEvent(BaseModel):
49
+ """An event to be dispatched to webhook endpoints."""
50
+
51
+ event_type: str
52
+ payload: dict[str, Any]
53
+ timestamp: datetime
54
+
55
+
56
+ # --- Request/Response models for the API layer ---
57
+
58
+
59
+ class CreateEndpointRequest(BaseModel):
60
+ """Payload for registering a new webhook endpoint."""
61
+
62
+ url: str = Field(min_length=1)
63
+ events: list[str] = Field(min_length=1)
64
+ metadata: dict[str, Any] = Field(default_factory=dict)
65
+
66
+
67
+ class UpdateEndpointRequest(BaseModel):
68
+ """Payload for updating an existing webhook endpoint."""
69
+
70
+ url: str | None = None
71
+ events: list[str] | None = None
72
+ active: bool | None = None
73
+ metadata: dict[str, Any] | None = None
74
+
75
+
76
+ class EndpointResponse(BaseModel):
77
+ """Response model for a webhook endpoint (hides full secret)."""
78
+
79
+ id: UUID
80
+ url: str
81
+ events: list[str]
82
+ secret: str = Field(description="Signing secret (shown once on creation)")
83
+ active: bool
84
+ created_at: datetime
85
+ metadata: dict[str, Any]
86
+
87
+
88
+ class DeliveryListResponse(BaseModel):
89
+ """Paginated list of deliveries."""
90
+
91
+ items: list[WebhookDelivery]
92
+ total: int
93
+
94
+
95
+ class TestEventResponse(BaseModel):
96
+ """Response after sending a test webhook event."""
97
+
98
+ delivery_id: UUID
99
+ status: DeliveryStatus
webhooks/router.py ADDED
@@ -0,0 +1,177 @@
1
+ """FastAPI router for webhook management endpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import UTC, datetime
6
+ from uuid import UUID
7
+
8
+ from errors import (
9
+ NotFoundError,
10
+ ValidationError,
11
+ )
12
+ from fastapi import APIRouter, Query
13
+
14
+ from webhooks.delivery import deliver_webhook
15
+ from webhooks.models import (
16
+ CreateEndpointRequest,
17
+ DeliveryListResponse,
18
+ DeliveryStatus,
19
+ EndpointResponse,
20
+ TestEventResponse,
21
+ UpdateEndpointRequest,
22
+ WebhookDelivery,
23
+ WebhookEndpoint,
24
+ WebhookEvent,
25
+ )
26
+ from webhooks.signing import generate_secret
27
+ from webhooks.store import (
28
+ create_endpoint,
29
+ delete_endpoint,
30
+ get_deliveries,
31
+ get_deliveries_count,
32
+ get_delivery,
33
+ get_endpoint,
34
+ list_endpoints,
35
+ update_endpoint,
36
+ )
37
+
38
+
39
+ def _endpoint_to_response(ep: WebhookEndpoint) -> EndpointResponse:
40
+ """Convert internal endpoint model to API response."""
41
+ return EndpointResponse(
42
+ id=ep.id,
43
+ url=ep.url,
44
+ events=ep.events,
45
+ secret=ep.secret,
46
+ active=ep.active,
47
+ created_at=ep.created_at,
48
+ metadata=ep.metadata,
49
+ )
50
+
51
+
52
+ def WebhookRouter(
53
+ *,
54
+ prefix: str = "/webhooks",
55
+ tags: list[str] | None = None,
56
+ ) -> APIRouter:
57
+ """Create a FastAPI router for webhook management endpoints.
58
+
59
+ Args:
60
+ prefix: URL prefix for all routes. Defaults to ``/webhooks``.
61
+ tags: OpenAPI tags for the router.
62
+
63
+ Returns:
64
+ A configured APIRouter.
65
+
66
+ Example::
67
+
68
+ router = WebhookRouter()
69
+ app.include_router(router)
70
+ """
71
+ router = APIRouter(prefix=prefix, tags=tags or ["webhooks"])
72
+
73
+ @router.post("/endpoints", response_model=EndpointResponse, status_code=201)
74
+ async def register_endpoint(body: CreateEndpointRequest) -> EndpointResponse:
75
+ """Register a new webhook endpoint."""
76
+ secret = generate_secret()
77
+ endpoint = await create_endpoint(
78
+ url=body.url,
79
+ events=body.events,
80
+ secret=secret,
81
+ metadata=body.metadata,
82
+ )
83
+ return _endpoint_to_response(endpoint)
84
+
85
+ @router.get("/endpoints", response_model=list[EndpointResponse])
86
+ async def list_all_endpoints(
87
+ active_only: bool = Query(False, description="Only return active endpoints"),
88
+ ) -> list[EndpointResponse]:
89
+ """List all registered webhook endpoints."""
90
+ endpoints = await list_endpoints(active_only=active_only)
91
+ return [_endpoint_to_response(ep) for ep in endpoints]
92
+
93
+ @router.get("/endpoints/{endpoint_id}", response_model=EndpointResponse)
94
+ async def get_single_endpoint(endpoint_id: UUID) -> EndpointResponse:
95
+ """Get a single webhook endpoint by ID."""
96
+ endpoint = await get_endpoint(endpoint_id)
97
+ if endpoint is None:
98
+ raise NotFoundError("Endpoint not found")
99
+ return _endpoint_to_response(endpoint)
100
+
101
+ @router.patch("/endpoints/{endpoint_id}", response_model=EndpointResponse)
102
+ async def patch_endpoint(endpoint_id: UUID, body: UpdateEndpointRequest) -> EndpointResponse:
103
+ """Update an existing webhook endpoint."""
104
+ updated = await update_endpoint(
105
+ endpoint_id,
106
+ url=body.url,
107
+ events=body.events,
108
+ active=body.active,
109
+ metadata=body.metadata,
110
+ )
111
+ if updated is None:
112
+ raise NotFoundError("Endpoint not found")
113
+ return _endpoint_to_response(updated)
114
+
115
+ @router.delete("/endpoints/{endpoint_id}", status_code=204)
116
+ async def remove_endpoint(endpoint_id: UUID) -> None:
117
+ """Delete a webhook endpoint."""
118
+ deleted = await delete_endpoint(endpoint_id)
119
+ if not deleted:
120
+ raise NotFoundError("Endpoint not found")
121
+
122
+ @router.get("/deliveries", response_model=DeliveryListResponse)
123
+ async def list_deliveries(
124
+ endpoint_id: UUID | None = Query(None, description="Filter by endpoint"),
125
+ status: DeliveryStatus | None = Query(None, description="Filter by status"),
126
+ limit: int = Query(50, ge=1, le=200),
127
+ offset: int = Query(0, ge=0),
128
+ ) -> DeliveryListResponse:
129
+ """List webhook deliveries with optional filters."""
130
+ items = await get_deliveries(
131
+ endpoint_id=endpoint_id,
132
+ status=status,
133
+ limit=limit,
134
+ offset=offset,
135
+ )
136
+ total = await get_deliveries_count(endpoint_id=endpoint_id, status=status)
137
+ return DeliveryListResponse(items=items, total=total)
138
+
139
+ @router.post("/deliveries/{delivery_id}/retry", response_model=WebhookDelivery)
140
+ async def retry_delivery(delivery_id: UUID) -> WebhookDelivery:
141
+ """Retry a single failed delivery."""
142
+ delivery = await get_delivery(delivery_id)
143
+ if delivery is None:
144
+ raise NotFoundError("Delivery not found")
145
+ if delivery.status != DeliveryStatus.FAILED:
146
+ raise ValidationError("Only failed deliveries can be retried")
147
+
148
+ endpoint = await get_endpoint(delivery.endpoint_id)
149
+ if endpoint is None or not endpoint.active:
150
+ raise ValidationError("Endpoint not found or inactive")
151
+
152
+ event = WebhookEvent(
153
+ event_type=delivery.event_type,
154
+ payload=delivery.payload,
155
+ timestamp=datetime.now(UTC),
156
+ )
157
+ result = await deliver_webhook(endpoint, event)
158
+ return result
159
+
160
+ @router.post("/test/{endpoint_id}", response_model=TestEventResponse)
161
+ async def send_test_event(endpoint_id: UUID) -> TestEventResponse:
162
+ """Send a test webhook event to the specified endpoint."""
163
+ endpoint = await get_endpoint(endpoint_id)
164
+ if endpoint is None:
165
+ raise NotFoundError("Endpoint not found")
166
+ if not endpoint.active:
167
+ raise ValidationError("Endpoint is inactive")
168
+
169
+ event = WebhookEvent(
170
+ event_type="webhook.test",
171
+ payload={"message": "This is a test webhook event", "endpoint_id": str(endpoint_id)},
172
+ timestamp=datetime.now(UTC),
173
+ )
174
+ delivery = await deliver_webhook(endpoint, event)
175
+ return TestEventResponse(delivery_id=delivery.id, status=delivery.status)
176
+
177
+ return router
webhooks/signing.py ADDED
@@ -0,0 +1,70 @@
1
+ """HMAC-SHA256 webhook signing and verification."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import hmac
7
+ import secrets
8
+ import time
9
+
10
+ from webhooks.config import get_config
11
+
12
+
13
+ def generate_secret() -> str:
14
+ """Generate a new webhook signing secret with the configured prefix.
15
+
16
+ Returns:
17
+ A string like ``whsec_<32-hex-chars>``.
18
+ """
19
+ config = get_config()
20
+ token = secrets.token_hex(32)
21
+ return f"{config.signing_secret_prefix}{token}"
22
+
23
+
24
+ def sign_payload(payload: str, secret: str, timestamp: int) -> str:
25
+ """Create an HMAC-SHA256 signature for the given payload.
26
+
27
+ The signed message is ``{timestamp}.{payload}`` to bind the signature to
28
+ a specific point in time.
29
+
30
+ Args:
31
+ payload: The JSON string body to sign.
32
+ secret: The endpoint's signing secret.
33
+ timestamp: Unix timestamp (seconds) when the event was sent.
34
+
35
+ Returns:
36
+ Hex-encoded HMAC-SHA256 signature.
37
+ """
38
+ message = f"{timestamp}.{payload}"
39
+ return hmac.new(
40
+ secret.encode("utf-8"),
41
+ message.encode("utf-8"),
42
+ hashlib.sha256,
43
+ ).hexdigest()
44
+
45
+
46
+ def verify_signature(
47
+ payload: str,
48
+ signature: str,
49
+ secret: str,
50
+ timestamp: int,
51
+ tolerance: int = 300,
52
+ ) -> bool:
53
+ """Verify an HMAC-SHA256 webhook signature.
54
+
55
+ Args:
56
+ payload: The JSON string body that was signed.
57
+ signature: The hex-encoded signature to verify.
58
+ secret: The endpoint's signing secret.
59
+ timestamp: Unix timestamp claimed by the sender.
60
+ tolerance: Maximum allowed age in seconds (default 5 minutes).
61
+
62
+ Returns:
63
+ True if the signature is valid and the timestamp is within tolerance.
64
+ """
65
+ now = int(time.time())
66
+ if abs(now - timestamp) > tolerance:
67
+ return False
68
+
69
+ expected = sign_payload(payload, secret, timestamp)
70
+ return hmac.compare_digest(expected, signature)
webhooks/store.py ADDED
@@ -0,0 +1,83 @@
1
+ """PostgreSQL storage operations for webhooks -- facade module.
2
+
3
+ Splits endpoint and delivery operations into separate files for readability,
4
+ but re-exports everything here so callers can ``from webhooks.store import ...``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from webhooks.config import get_pool
10
+ from webhooks.store_deliveries import (
11
+ get_deliveries,
12
+ get_deliveries_count,
13
+ get_delivery,
14
+ get_failed_deliveries,
15
+ record_delivery,
16
+ update_delivery,
17
+ )
18
+ from webhooks.store_endpoints import (
19
+ create_endpoint,
20
+ delete_endpoint,
21
+ get_endpoint,
22
+ list_endpoints,
23
+ update_endpoint,
24
+ )
25
+
26
+
27
+ async def create_tables() -> None:
28
+ """Create the webhook_endpoints and webhook_deliveries tables if they do not exist."""
29
+ pool = get_pool()
30
+ async with pool.acquire() as conn:
31
+ await conn.execute("""
32
+ CREATE TABLE IF NOT EXISTS webhook_endpoints (
33
+ id UUID PRIMARY KEY,
34
+ url TEXT NOT NULL,
35
+ events JSONB NOT NULL DEFAULT '[]',
36
+ secret TEXT NOT NULL,
37
+ active BOOLEAN NOT NULL DEFAULT TRUE,
38
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
39
+ metadata JSONB NOT NULL DEFAULT '{}'
40
+ )
41
+ """)
42
+ await conn.execute("""
43
+ CREATE INDEX IF NOT EXISTS idx_webhook_endpoints_active
44
+ ON webhook_endpoints (active) WHERE active = TRUE
45
+ """)
46
+ await conn.execute("""
47
+ CREATE TABLE IF NOT EXISTS webhook_deliveries (
48
+ id UUID PRIMARY KEY,
49
+ endpoint_id UUID NOT NULL REFERENCES webhook_endpoints(id) ON DELETE CASCADE,
50
+ event_type TEXT NOT NULL,
51
+ payload JSONB NOT NULL DEFAULT '{}',
52
+ status TEXT NOT NULL DEFAULT 'pending',
53
+ attempts INTEGER NOT NULL DEFAULT 0,
54
+ last_attempt_at TIMESTAMPTZ,
55
+ response_status INTEGER,
56
+ response_body TEXT,
57
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
58
+ )
59
+ """)
60
+ await conn.execute("""
61
+ CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_endpoint
62
+ ON webhook_deliveries (endpoint_id, created_at DESC)
63
+ """)
64
+ await conn.execute("""
65
+ CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_failed
66
+ ON webhook_deliveries (status) WHERE status = 'failed'
67
+ """)
68
+
69
+
70
+ __all__ = [
71
+ "create_endpoint",
72
+ "create_tables",
73
+ "delete_endpoint",
74
+ "get_deliveries",
75
+ "get_deliveries_count",
76
+ "get_delivery",
77
+ "get_endpoint",
78
+ "get_failed_deliveries",
79
+ "list_endpoints",
80
+ "record_delivery",
81
+ "update_delivery",
82
+ "update_endpoint",
83
+ ]
@@ -0,0 +1,194 @@
1
+ """PostgreSQL storage operations for webhook deliveries (asyncpg, no ORM)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import uuid
7
+ from datetime import UTC, datetime
8
+ from typing import Any
9
+
10
+ from webhooks.config import get_pool
11
+ from webhooks.models import DeliveryStatus, WebhookDelivery
12
+
13
+ _DELIVERY_COLS = (
14
+ "id, endpoint_id, event_type, payload, status, attempts,"
15
+ " last_attempt_at, response_status, response_body, created_at"
16
+ )
17
+
18
+
19
+ def _row_to_delivery(row: dict) -> WebhookDelivery:
20
+ """Convert an asyncpg Record to a WebhookDelivery model."""
21
+ payload = row["payload"]
22
+ if isinstance(payload, str):
23
+ payload = json.loads(payload)
24
+ return WebhookDelivery(
25
+ id=row["id"],
26
+ endpoint_id=row["endpoint_id"],
27
+ event_type=row["event_type"],
28
+ payload=payload,
29
+ status=DeliveryStatus(row["status"]),
30
+ attempts=row["attempts"],
31
+ last_attempt_at=row["last_attempt_at"],
32
+ response_status=row["response_status"],
33
+ response_body=row["response_body"],
34
+ created_at=row["created_at"],
35
+ )
36
+
37
+
38
+ async def record_delivery(
39
+ endpoint_id: uuid.UUID,
40
+ event_type: str,
41
+ payload: dict[str, Any],
42
+ ) -> WebhookDelivery:
43
+ """Record a new pending delivery."""
44
+ pool = get_pool()
45
+ delivery_id = uuid.uuid4()
46
+ now = datetime.now(UTC)
47
+
48
+ async with pool.acquire() as conn:
49
+ await conn.execute(
50
+ """
51
+ INSERT INTO webhook_deliveries
52
+ (id, endpoint_id, event_type, payload, status, attempts, created_at)
53
+ VALUES ($1, $2, $3, $4::jsonb, 'pending', 0, $5)
54
+ """,
55
+ delivery_id,
56
+ endpoint_id,
57
+ event_type,
58
+ json.dumps(payload),
59
+ now,
60
+ )
61
+
62
+ return WebhookDelivery(
63
+ id=delivery_id,
64
+ endpoint_id=endpoint_id,
65
+ event_type=event_type,
66
+ payload=payload,
67
+ status=DeliveryStatus.PENDING,
68
+ attempts=0,
69
+ last_attempt_at=None,
70
+ response_status=None,
71
+ response_body=None,
72
+ created_at=now,
73
+ )
74
+
75
+
76
+ async def update_delivery(
77
+ delivery_id: uuid.UUID,
78
+ *,
79
+ status: DeliveryStatus,
80
+ attempts: int,
81
+ response_status: int | None = None,
82
+ response_body: str | None = None,
83
+ ) -> None:
84
+ """Update a delivery record after an attempt."""
85
+ pool = get_pool()
86
+ now = datetime.now(UTC)
87
+ async with pool.acquire() as conn:
88
+ await conn.execute(
89
+ """
90
+ UPDATE webhook_deliveries
91
+ SET status = $1, attempts = $2, last_attempt_at = $3,
92
+ response_status = $4, response_body = $5
93
+ WHERE id = $6
94
+ """,
95
+ status.value,
96
+ attempts,
97
+ now,
98
+ response_status,
99
+ response_body,
100
+ delivery_id,
101
+ )
102
+
103
+
104
+ async def get_delivery(delivery_id: uuid.UUID) -> WebhookDelivery | None:
105
+ """Fetch a single delivery by ID."""
106
+ pool = get_pool()
107
+ async with pool.acquire() as conn:
108
+ row = await conn.fetchrow(
109
+ f"SELECT {_DELIVERY_COLS} FROM webhook_deliveries WHERE id = $1",
110
+ delivery_id,
111
+ )
112
+ if row is None:
113
+ return None
114
+ return _row_to_delivery(dict(row))
115
+
116
+
117
+ async def get_deliveries(
118
+ endpoint_id: uuid.UUID | None = None,
119
+ status: DeliveryStatus | None = None,
120
+ limit: int = 50,
121
+ offset: int = 0,
122
+ ) -> list[WebhookDelivery]:
123
+ """Fetch deliveries with optional filters."""
124
+ pool = get_pool()
125
+ conditions: list[str] = []
126
+ args: list[Any] = []
127
+ idx = 1
128
+
129
+ if endpoint_id is not None:
130
+ conditions.append(f"endpoint_id = ${idx}")
131
+ args.append(endpoint_id)
132
+ idx += 1
133
+ if status is not None:
134
+ conditions.append(f"status = ${idx}")
135
+ args.append(status.value)
136
+ idx += 1
137
+
138
+ where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
139
+ query = f"""
140
+ SELECT {_DELIVERY_COLS}
141
+ FROM webhook_deliveries {where}
142
+ ORDER BY created_at DESC
143
+ LIMIT ${idx} OFFSET ${idx + 1}
144
+ """
145
+ args.extend([limit, offset])
146
+
147
+ async with pool.acquire() as conn:
148
+ rows = await conn.fetch(query, *args)
149
+
150
+ return [_row_to_delivery(dict(row)) for row in rows]
151
+
152
+
153
+ async def get_deliveries_count(
154
+ endpoint_id: uuid.UUID | None = None,
155
+ status: DeliveryStatus | None = None,
156
+ ) -> int:
157
+ """Count deliveries with optional filters."""
158
+ pool = get_pool()
159
+ conditions: list[str] = []
160
+ args: list[Any] = []
161
+ idx = 1
162
+
163
+ if endpoint_id is not None:
164
+ conditions.append(f"endpoint_id = ${idx}")
165
+ args.append(endpoint_id)
166
+ idx += 1
167
+ if status is not None:
168
+ conditions.append(f"status = ${idx}")
169
+ args.append(status.value)
170
+ idx += 1
171
+
172
+ where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
173
+ query = f"SELECT COUNT(*) FROM webhook_deliveries {where}"
174
+
175
+ async with pool.acquire() as conn:
176
+ count = await conn.fetchval(query, *args)
177
+
178
+ return count or 0
179
+
180
+
181
+ async def get_failed_deliveries(max_attempts: int = 5) -> list[WebhookDelivery]:
182
+ """Fetch failed deliveries that haven't exceeded max retry attempts."""
183
+ pool = get_pool()
184
+ async with pool.acquire() as conn:
185
+ rows = await conn.fetch(
186
+ f"""
187
+ SELECT {_DELIVERY_COLS}
188
+ FROM webhook_deliveries
189
+ WHERE status = 'failed' AND attempts < $1
190
+ ORDER BY created_at ASC
191
+ """,
192
+ max_attempts,
193
+ )
194
+ return [_row_to_delivery(dict(row)) for row in rows]
@@ -0,0 +1,153 @@
1
+ """PostgreSQL storage operations for webhook endpoints (asyncpg, no ORM)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import uuid
7
+ from datetime import UTC, datetime
8
+ from typing import Any
9
+
10
+ from webhooks.config import get_pool
11
+ from webhooks.models import WebhookEndpoint
12
+
13
+ _ENDPOINT_COLS = "id, url, events, secret, active, created_at, metadata"
14
+
15
+
16
+ def _row_to_endpoint(row: dict) -> WebhookEndpoint:
17
+ """Convert an asyncpg Record to a WebhookEndpoint model."""
18
+ events = row["events"]
19
+ if isinstance(events, str):
20
+ events = json.loads(events)
21
+ metadata = row["metadata"]
22
+ if isinstance(metadata, str):
23
+ metadata = json.loads(metadata)
24
+ return WebhookEndpoint(
25
+ id=row["id"],
26
+ url=row["url"],
27
+ events=events,
28
+ secret=row["secret"],
29
+ active=row["active"],
30
+ created_at=row["created_at"],
31
+ metadata=metadata,
32
+ )
33
+
34
+
35
+ async def create_endpoint(
36
+ url: str,
37
+ events: list[str],
38
+ secret: str,
39
+ metadata: dict[str, Any] | None = None,
40
+ ) -> WebhookEndpoint:
41
+ """Insert a new webhook endpoint and return it."""
42
+ pool = get_pool()
43
+ endpoint_id = uuid.uuid4()
44
+ now = datetime.now(UTC)
45
+ meta = metadata or {}
46
+
47
+ async with pool.acquire() as conn:
48
+ await conn.execute(
49
+ """
50
+ INSERT INTO webhook_endpoints (id, url, events, secret, active, created_at, metadata)
51
+ VALUES ($1, $2, $3::jsonb, $4, TRUE, $5, $6::jsonb)
52
+ """,
53
+ endpoint_id,
54
+ url,
55
+ json.dumps(events),
56
+ secret,
57
+ now,
58
+ json.dumps(meta),
59
+ )
60
+
61
+ return WebhookEndpoint(
62
+ id=endpoint_id,
63
+ url=url,
64
+ events=events,
65
+ secret=secret,
66
+ active=True,
67
+ created_at=now,
68
+ metadata=meta,
69
+ )
70
+
71
+
72
+ async def get_endpoint(endpoint_id: uuid.UUID) -> WebhookEndpoint | None:
73
+ """Fetch a single endpoint by ID. Returns None if not found."""
74
+ pool = get_pool()
75
+ async with pool.acquire() as conn:
76
+ row = await conn.fetchrow(
77
+ f"SELECT {_ENDPOINT_COLS} FROM webhook_endpoints WHERE id = $1",
78
+ endpoint_id,
79
+ )
80
+ if row is None:
81
+ return None
82
+ return _row_to_endpoint(dict(row))
83
+
84
+
85
+ async def list_endpoints(active_only: bool = False) -> list[WebhookEndpoint]:
86
+ """List all webhook endpoints, optionally filtering to active ones."""
87
+ pool = get_pool()
88
+ where = "WHERE active = TRUE" if active_only else ""
89
+ async with pool.acquire() as conn:
90
+ rows = await conn.fetch(
91
+ f"SELECT {_ENDPOINT_COLS} FROM webhook_endpoints {where} ORDER BY created_at DESC"
92
+ )
93
+ return [_row_to_endpoint(dict(row)) for row in rows]
94
+
95
+
96
+ async def update_endpoint(
97
+ endpoint_id: uuid.UUID,
98
+ *,
99
+ url: str | None = None,
100
+ events: list[str] | None = None,
101
+ active: bool | None = None,
102
+ metadata: dict[str, Any] | None = None,
103
+ ) -> WebhookEndpoint | None:
104
+ """Update an endpoint's fields. Returns the updated endpoint or None if not found."""
105
+ pool = get_pool()
106
+ sets: list[str] = []
107
+ args: list[Any] = []
108
+ idx = 1
109
+
110
+ if url is not None:
111
+ sets.append(f"url = ${idx}")
112
+ args.append(url)
113
+ idx += 1
114
+ if events is not None:
115
+ sets.append(f"events = ${idx}::jsonb")
116
+ args.append(json.dumps(events))
117
+ idx += 1
118
+ if active is not None:
119
+ sets.append(f"active = ${idx}")
120
+ args.append(active)
121
+ idx += 1
122
+ if metadata is not None:
123
+ sets.append(f"metadata = ${idx}::jsonb")
124
+ args.append(json.dumps(metadata))
125
+ idx += 1
126
+
127
+ if not sets:
128
+ return await get_endpoint(endpoint_id)
129
+
130
+ args.append(endpoint_id)
131
+ query = f"""
132
+ UPDATE webhook_endpoints SET {", ".join(sets)}
133
+ WHERE id = ${idx}
134
+ RETURNING {_ENDPOINT_COLS}
135
+ """
136
+
137
+ async with pool.acquire() as conn:
138
+ row = await conn.fetchrow(query, *args)
139
+
140
+ if row is None:
141
+ return None
142
+ return _row_to_endpoint(dict(row))
143
+
144
+
145
+ async def delete_endpoint(endpoint_id: uuid.UUID) -> bool:
146
+ """Delete an endpoint by ID. Returns True if it existed."""
147
+ pool = get_pool()
148
+ async with pool.acquire() as conn:
149
+ result = await conn.execute(
150
+ "DELETE FROM webhook_endpoints WHERE id = $1",
151
+ endpoint_id,
152
+ )
153
+ return result == "DELETE 1"