Skip to content

Commit 65f84d2

Browse files
authored
feat: add webhook URL policy and wholesale feed sender
Adds stable webhook URL policy validation and wholesale feed notification sender exports/helpers.
1 parent e00290c commit 65f84d2

10 files changed

Lines changed: 833 additions & 6 deletions

README.md

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -522,7 +522,45 @@ async with sender:
522522
9421 signing, and the httpx POST in one call. `send_raw(...)` is an escape
523523
hatch for custom payload shapes; dedicated methods exist for every webhook
524524
kind (`send_revocation_notification`, `send_artifact_webhook`,
525-
`send_collection_list_changed`, `send_property_list_changed`).
525+
`send_collection_list_changed`, `send_property_list_changed`,
526+
`send_wholesale_feed`).
527+
528+
Validate buyer-provided webhook URLs before storing durable subscriptions:
529+
530+
```python
531+
from adcp.webhooks import WebhookDestinationPolicy, validate_webhook_destination_url
532+
533+
validate_webhook_destination_url(
534+
request.push_notification_config.url,
535+
field="push_notification_config.url",
536+
policy=WebhookDestinationPolicy.production(),
537+
)
538+
```
539+
540+
Use `WebhookDestinationPolicy.local_development()` only for local tests that
541+
need `http://localhost` or private-network destinations. Production validation
542+
requires HTTPS and rejects loopback, private, link-local, reserved, and cloud
543+
metadata destinations using the same SSRF classifier as `WebhookSender`. The
544+
validation result includes both `original_url` and `effective_url`; sellers
545+
should normally persist the buyer's original URL and reapply the same
546+
policy/hooks at send time, rather than storing a Docker or test rewrite.
547+
548+
Wholesale feed notifications use stable types from `adcp` / `adcp.types`:
549+
550+
```python
551+
from adcp import NotificationConfig, WholesaleFeedEvent, WholesaleFeedWebhook
552+
from adcp.webhooks import WebhookSender
553+
554+
if "product.updated" in subscription.event_types:
555+
await sender.send_wholesale_feed_to_subscription(
556+
subscription=subscription,
557+
account_id=account_id,
558+
notification_type="product.updated",
559+
wholesale_feed_version=feed_version,
560+
cache_scope="public",
561+
event=event,
562+
)
563+
```
526564

527565
The webhook-signing JWK MUST be published in your `adagents.json` with
528566
`adcp_use: "webhook-signing"` — distinct from your `request-signing` key so

docs/handler-authoring.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1214,6 +1214,55 @@ Pick one per `WebhookSender` instance. All three share the same
12141214
| `WebhookSender.from_bearer_token(token)` | `Authorization: Bearer` | Simplest; no key management; requires TLS |
12151215
| `WebhookSender.from_standard_webhooks_secret(secret, key_id=...)` | Standard Webhooks v1 | Svix / Resend / standardwebhooks.com receivers |
12161216

1217+
### Registration-time URL validation
1218+
1219+
Validate durable buyer endpoints before persisting `push_notification_config.url`
1220+
or `accounts[].notification_configs[].url` from `sync_accounts`:
1221+
1222+
```python
1223+
from adcp.webhooks import (
1224+
WebhookDestinationPolicy,
1225+
WebhookDestinationValidationError,
1226+
validate_webhook_destination_url,
1227+
)
1228+
1229+
try:
1230+
validate_webhook_destination_url(
1231+
config.url,
1232+
field="accounts[0].notification_configs[0].url",
1233+
policy=WebhookDestinationPolicy.production(),
1234+
)
1235+
except WebhookDestinationValidationError as exc:
1236+
return {"errors": [exc.to_error()]}
1237+
```
1238+
1239+
Production policy requires HTTPS and rejects private, loopback, link-local,
1240+
reserved, and cloud metadata destinations. Use
1241+
`WebhookDestinationPolicy.local_development()` only for local fixtures that
1242+
need `http://localhost` or private-network endpoints. The helper returns both
1243+
`original_url` and `effective_url`; persist the buyer's original URL in durable
1244+
subscription state, and reapply the same policy/hooks when sending. Do not
1245+
persist a Docker or test rewrite as the buyer's registered endpoint.
1246+
1247+
### Wholesale feed notifications
1248+
1249+
`NotificationConfig`, `WholesaleFeedEvent`, and `WholesaleFeedWebhook` are
1250+
stable exports from both `adcp` and `adcp.types`. When firing account-scoped
1251+
catalog notifications, preserve the subscriber filter and send through
1252+
`WebhookSender`:
1253+
1254+
```python
1255+
if event_type in subscription.event_types:
1256+
await sender.send_wholesale_feed_to_subscription(
1257+
subscription=subscription,
1258+
account_id=account_id,
1259+
notification_type=event_type,
1260+
wholesale_feed_version=feed_version,
1261+
cache_scope="public",
1262+
event=event,
1263+
)
1264+
```
1265+
12171266
### Sender vs. supervisor
12181267

12191268
`WebhookSender` is the transport layer — it constructs and signs one HTTP POST.

src/adcp/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,7 @@
218218
MediaBuyPackage,
219219
MediaBuyStatus,
220220
MediaChannel,
221+
NotificationConfig,
221222
OfferingAssetConstraint,
222223
OfferingAssetGroup,
223224
# Optimization
@@ -286,6 +287,8 @@
286287
VerifyBrandClaimsRequestBulk,
287288
VerifyBrandClaimsResponseBulk,
288289
WcagLevel,
290+
WholesaleFeedEvent,
291+
WholesaleFeedWebhook,
289292
aliases,
290293
)
291294

@@ -867,6 +870,9 @@ def get_adcp_version() -> str:
867870
"SignalPricingOption",
868871
# Configuration types
869872
"PushNotificationConfig",
873+
"NotificationConfig",
874+
"WholesaleFeedEvent",
875+
"WholesaleFeedWebhook",
870876
# Adagents validation
871877
"AdAgentsValidationResult",
872878
"AdagentsCacheEntry",

src/adcp/types/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,7 @@
251251
MediaBuyStatus,
252252
MediaChannel,
253253
Metadata,
254+
NotificationConfig,
254255
NotificationType,
255256
Offering,
256257
OfferingAssetConstraint,
@@ -395,6 +396,8 @@
395396
ViewThreshold,
396397
WcagLevel,
397398
WebhookResponseType,
399+
WholesaleFeedEvent,
400+
WholesaleFeedWebhook,
398401
)
399402
from adcp.types._generated import (
400403
AudioAsset as AudioContent,
@@ -1128,6 +1131,7 @@ def __init__(self, *args: object, **kwargs: object) -> None:
11281131
"AuthorizedAgents",
11291132
"AvailableMetric",
11301133
"PushNotificationConfig",
1134+
"NotificationConfig",
11311135
"ReportingCapabilities",
11321136
"ReportingFrequency",
11331137
"ReportingPeriod",
@@ -1162,6 +1166,8 @@ def __init__(self, *args: object, **kwargs: object) -> None:
11621166
"WebhookMetadata",
11631167
# Webhook types
11641168
"McpWebhookPayload",
1169+
"WholesaleFeedEvent",
1170+
"WholesaleFeedWebhook",
11651171
# Semantic aliases for discriminated unions
11661172
"ActivateSignalErrorResponse",
11671173
"ActivateSignalSuccessResponse",

src/adcp/webhook_sender.py

Lines changed: 159 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,9 @@
2929

3030
import json
3131
import warnings
32-
from collections.abc import Mapping
32+
from collections.abc import Mapping, Sequence
3333
from dataclasses import dataclass, field
34-
from datetime import datetime
34+
from datetime import datetime, timezone
3535
from pathlib import Path
3636
from typing import Any
3737

@@ -50,7 +50,14 @@
5050
build_async_ip_pinned_transport,
5151
)
5252
from adcp.signing.standard_webhooks import decode_secret as _decode_sw_secret
53-
from adcp.types import AdcpProtocol, GeneratedTaskStatus, TaskType
53+
from adcp.types import (
54+
AdcpProtocol,
55+
GeneratedTaskStatus,
56+
NotificationConfig,
57+
TaskType,
58+
WholesaleFeedEvent,
59+
WholesaleFeedWebhook,
60+
)
5461
from adcp.types.generated_poc.core.async_response_data import AdcpAsyncResponseData
5562
from adcp.webhook_auth import (
5663
AdcpLegacyHmacStrategy,
@@ -121,6 +128,24 @@ def _validate_hooks(hooks: tuple[TransportHook, ...], allow_private_destinations
121128
validate(allow_private_destinations=allow_private_destinations)
122129

123130

131+
def _entity_type_for_wholesale_notification(notification_type: str) -> str:
132+
if notification_type.startswith("product."):
133+
return "product"
134+
if notification_type.startswith("signal."):
135+
return "signal"
136+
if notification_type == "wholesale_feed.bulk_change":
137+
return "feed"
138+
raise ValueError(
139+
f"unsupported wholesale feed notification_type {notification_type!r}; "
140+
"expected product.*, signal.*, or wholesale_feed.bulk_change"
141+
)
142+
143+
144+
def _enum_value(value: Any) -> str:
145+
raw = getattr(value, "value", value)
146+
return str(raw)
147+
148+
124149
@dataclass(frozen=True)
125150
class WebhookDeliveryResult:
126151
"""Outcome of one ``send_*`` call.
@@ -680,6 +705,137 @@ async def send_property_list_changed(
680705
url=url, idempotency_key=key, payload=payload, extra_headers=extra_headers
681706
)
682707

708+
async def send_wholesale_feed(
709+
self,
710+
*,
711+
url: str,
712+
subscriber_id: str,
713+
account_id: str,
714+
notification_type: str,
715+
wholesale_feed_version: str,
716+
cache_scope: str,
717+
event: WholesaleFeedEvent | Mapping[str, Any],
718+
previous_wholesale_feed_version: str | None = None,
719+
fired_at: datetime | None = None,
720+
idempotency_key: str | None = None,
721+
subscription_event_types: Sequence[Any] | None = None,
722+
extra_headers: Mapping[str, str] | None = None,
723+
) -> WebhookDeliveryResult:
724+
"""POST a signed account-scoped wholesale feed notification.
725+
726+
``subscription_event_types`` is optional but recommended when the
727+
caller is sending to an ``accounts[].notification_configs[]`` entry:
728+
pass that entry's ``event_types`` to fail closed if the subscription
729+
did not request this notification type.
730+
"""
731+
732+
if not isinstance(subscriber_id, str) or not subscriber_id:
733+
raise ValueError("subscriber_id must be a non-empty string")
734+
if not isinstance(account_id, str) or not account_id:
735+
raise ValueError("account_id must be a non-empty string")
736+
if not isinstance(wholesale_feed_version, str) or not wholesale_feed_version:
737+
raise ValueError("wholesale_feed_version must be a non-empty string")
738+
739+
event_model = event
740+
if not isinstance(event_model, WholesaleFeedEvent):
741+
event_model = WholesaleFeedEvent.model_validate(event_model)
742+
notification_type_value = _enum_value(notification_type)
743+
event_type = _enum_value(event_model.event_type)
744+
entity_type = _enum_value(event_model.entity_type)
745+
if notification_type_value != event_type:
746+
raise ValueError(
747+
"notification_type must match event.event_type "
748+
f"(got {notification_type_value!r}, event has {event_type!r})"
749+
)
750+
if subscription_event_types is not None:
751+
allowed_event_types = {_enum_value(item) for item in subscription_event_types}
752+
else:
753+
allowed_event_types = None
754+
if allowed_event_types is not None and notification_type_value not in allowed_event_types:
755+
raise ValueError(
756+
"notification_type is not present in the subscription's event_types; "
757+
"sellers must not silently widen account notification filters"
758+
)
759+
760+
expected_entity_type = _entity_type_for_wholesale_notification(notification_type_value)
761+
if entity_type != expected_entity_type:
762+
raise ValueError(
763+
"event.entity_type does not match notification_type "
764+
f"(got {entity_type!r}, expected {expected_entity_type!r})"
765+
)
766+
767+
cache_scope_value = _enum_value(cache_scope)
768+
applies_to = getattr(event_model.payload, "applies_to", None)
769+
applies_to_scope = _enum_value(getattr(applies_to, "scope", None))
770+
if applies_to_scope != cache_scope_value:
771+
raise ValueError(
772+
"cache_scope must match event.payload.applies_to.scope "
773+
f"(got {cache_scope_value!r}, event has {applies_to_scope!r})"
774+
)
775+
776+
key = idempotency_key or generate_webhook_idempotency_key()
777+
timestamp = fired_at or datetime.now(timezone.utc)
778+
webhook = WholesaleFeedWebhook.model_validate(
779+
{
780+
"idempotency_key": key,
781+
"notification_id": event_model.event_id,
782+
"notification_type": notification_type_value,
783+
"fired_at": timestamp,
784+
"subscriber_id": subscriber_id,
785+
"account_id": account_id,
786+
"wholesale_feed_version": wholesale_feed_version,
787+
"previous_wholesale_feed_version": previous_wholesale_feed_version,
788+
"cache_scope": cache_scope_value,
789+
"event": event_model,
790+
}
791+
)
792+
return await self.send_raw(
793+
url=url,
794+
idempotency_key=key,
795+
payload=webhook.model_dump(mode="json", exclude_none=True),
796+
extra_headers=extra_headers,
797+
)
798+
799+
async def send_wholesale_feed_to_subscription(
800+
self,
801+
*,
802+
subscription: NotificationConfig | Mapping[str, Any],
803+
account_id: str,
804+
notification_type: str,
805+
wholesale_feed_version: str,
806+
cache_scope: str,
807+
event: WholesaleFeedEvent | Mapping[str, Any],
808+
previous_wholesale_feed_version: str | None = None,
809+
fired_at: datetime | None = None,
810+
idempotency_key: str | None = None,
811+
extra_headers: Mapping[str, str] | None = None,
812+
) -> WebhookDeliveryResult:
813+
"""POST a wholesale feed notification to a ``NotificationConfig``.
814+
815+
This convenience wrapper keeps ``url``, ``subscriber_id``, and
816+
``event_types`` coupled to the same persisted subscription entry.
817+
"""
818+
819+
config = (
820+
subscription
821+
if isinstance(subscription, NotificationConfig)
822+
else NotificationConfig.model_validate(subscription)
823+
)
824+
return await self.send_wholesale_feed(
825+
url=str(config.url),
826+
subscriber_id=config.subscriber_id,
827+
account_id=account_id,
828+
notification_type=notification_type,
829+
wholesale_feed_version=wholesale_feed_version,
830+
cache_scope=cache_scope,
831+
event=event,
832+
previous_wholesale_feed_version=previous_wholesale_feed_version,
833+
fired_at=fired_at,
834+
idempotency_key=idempotency_key,
835+
subscription_event_types=config.event_types,
836+
extra_headers=extra_headers,
837+
)
838+
683839
async def send_raw(
684840
self,
685841
*,

0 commit comments

Comments
 (0)