feat(webhooks): webhook_activity[] on get_media_buys + standardized log surface (#4278, #4582 track 4)#4701
Conversation
…4278, #4582 track 4) Two request fields, one response field on get_media_buys for buyer-side webhook delivery visibility, plus the canonical record + truncation sentinel + pattern documentation that #4582 track 4 generalizes from it. Request additions (get-media-buys-request.json): - include_webhook_activity (bool, default false) - webhook_activity_limit (int 1-200, default 50) Response addition (get-media-buys-response.json items): - webhook_activity[] (maxItems 200), $ref to the canonical record New shared core schemas: - /schemas/core/webhook-activity-record.json — canonical record shape for any AdCP resource exposing a webhook_activity[] log. Fields: idempotency_key (equals the payload's dedup key — no parallel delivery_id), subscriber_id (reserved for multi-subscriber per #3009), fired_at, completed_at, notification_type (refs the shared enum), sequence_number, attempt, status (success / failed / timeout / connection_error / pending), url (query+fragment stripped), and privacy-bounded error_message. Nullable fields use draft-07 union types. Top-level additionalProperties: false with ext escape — a deliberate departure from convention to enforce uniform-across- resources at the schema level. - /schemas/core/truncation-sentinel.json — universal AdCP sentinel for fields whose content is truncated at a size cap. _meta envelope with truncated/original_size_bytes/preview/preview_format (open string). _meta.additionalProperties: true for forward-compat. No field consumes it today; lands now so future RFCs (notably the deferred include_webhook_payloads extension) plug into a shared shape. Normative resolutions: - Retention is MUST, not SHOULD: 30 days from completed_at uniformly across success/failed/timeout/connection_error; pending records run from fired_at until termination then transition to 30d from completed_at. Sellers unable to honor MUST omit the field via the three-state semantics. - Three-state presence: omitted = seller does not surface; [] = persists but no recent fires; non-empty = records. Buyers diagnose omission via push_notification_config registration state + propagation_surfaces capability declaration. - One record per attempt; retry trails share idempotency_key. - URL query+fragment stripped; secret-shaped path segments SHOULD be redacted. error_message is classification only — never bodies or headers. Pattern documentation: - docs/protocol/snapshot-and-log.mdx adds the normative § Webhook activity log pattern with a 7-item adoption checklist so future consumers (creative lifecycle #2261, per-account notification reads under #4582 track 3 in 3.2) have unambiguous MUST hooks. - docs/building/by-layer/L3/webhooks.mdx adds "Diagnosing missing fires" back-link. - docs/media-buy/task-reference/get_media_buys.mdx documents the call site with field table, status semantics, three-state presence, retention, privacy, omission diagnosis, and a JS/TS + Python example using the canonical WebhookActivityRecord type that the SDK regenerates from /schemas/core/. #4582 scope: tracks 1 (snapshot/log duality doc) and 2 (persistent webhook contract incl. auth renewal) were already shipped on main; this PR extends track 1 with the pattern section and back-links from track 2. Track 3 (per-account subscription model) deferred to 3.2.0 to compose cleanly with #3009 multi-subscriber direction. Tracks 5-7 on separate cadence per the epic. Closes #4278. Lands #4582 track 4. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Re-scoping note before this merges: I want to reverse the track-3 deferral and land per-account subscription in 3.1 alongside #2261, not 3.2. Reasoning: creative lifecycle is the only consumer with no workable per-resource anchor in 3.1. #2853 (dependency impact) and #1711 (compliance) can limp on per-buy #3009 composition still works — per-account anchor and multi-subscriber are orthogonal axes. Ship per-account-single-subscriber in 3.1 with This doesn't block #4701 — track 4 still ships here. Just want to remove the "track 3 → 3.2.0" framing from the PR body before merge, and pull #2261 into 3.1.0 as the proving ground. |
…t in adoption checklist The pattern doc adoption checklist was missing the upstream requirement: a webhook_activity[] surface only matters when there are fires, and fires only happen against a registered subscription anchor. Media buys have one today (per-buy push_notification_config). Resources that outlive a media buy (creatives under #2261, audiences, properties, account-level governance under #1711) do not — they are blocked on #4582 track 3's per-account subscription model, which is deferred to 3.2.0. Without this prerequisite in the checklist, a naive reader of #2261 would think they can $ref the canonical record and ship. They cannot. Changes: - Promote the subscription anchor to item 1 of the adoption checklist; renumber the existing items 2-8. - Rewrite the "Today's consumers" section as "Today's consumers and the dependency chain" — explicitly trace why creatives / audiences / properties / account-level governance are blocked on track 3, why the chain is track 3 first then adoption, and that #2261's own RFC scopes to creative-specific payload + state-machine while inheriting transport, subscription, and observability from #4582. - Update changeset to reflect the 8-item checklist and the dependency chain framing. Follow-up to #2261 comment from 2026-05-16 confirming #4582 epic dependency. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
DX review on the prior commit (08d9873) flagged six items: one must-fix wording change ("registered subscription model" masks a real primitive distinction between per-buy push_notification_config and the future per-account subscription resource) plus five polish items. All applied. Changes: - Item 1 of the adoption checklist: "Subscription anchor" → "Notification channel" framing. The two primitives are explicitly distinguished (buy-scoped config blob vs account-scoped subscription resource) so agents generating client code don't pattern-match them as the same thing. - Item 1 leads with a one-clause reassurance for media-buy-side readers ("Media buys satisfy this today via per-buy push_notification_config; resources outliving a buy wait on #4582 track 3") so they don't have to read the next paragraph to discover they aren't blocked. - Section heading "Today's consumers and the dependency chain" → split into "Consumers and the dependency chain" with H4 subsections "Today (3.1)" and "Blocked on track 3 (3.2.0+)". The previous heading was ~80% future-consumers content under a "today" label. - "subscription anchor" → "notification channel" throughout the section for terminology consistency. - Changeset: dependency-chain framing extracted from the documentation bullet into its own "Dependency chain (informational)" subsection beside "Scope of this PR within #4582" — keeps "we shipped this" as the lede; dependency story is secondary context. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…orm-agnostic lint) The platform-agnosticism lint regex `(^|_)<vendor>(_|$)` matched `meta` in `_meta` against the Meta Platforms vendor token. The leading underscore is supposed to signal "control marker, not vendor reference" but the lint regex doesn't know that distinction. Rather than allowlist `_meta` globally — which would create a small vendor-leak hole for any future schema — rename to `_truncation`: - Purpose-specific name; reader immediately understands intent. - Simpler discriminator: presence of `_truncation` key on the object, not `_meta.truncated: true` with a redundant boolean. - No lint suppression needed; no risk to future vendor-token coverage. - No real industry-convention loss — Stripe's `_metadata`/`object` patterns are framework-shaped; AdCP's discriminator is purpose-shaped. Drop the redundant `truncated: true` field — `_truncation` key presence IS the truth value. Schema delta: -1 property, +1 required slot rename. Changeset updated to reflect new shape. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
Buyer-side webhook delivery visibility for AdCP 3.1 — closes the operator-ticket loop for "did the publisher fire? did my endpoint receive it? was the retry trail clean?" Lands as two request fields + one response field on
get_media_buys, with the canonical record + truncation sentinel + pattern documentation that#4582track 4 generalizes from this seed.Closes #4278. Lands #4582 track 4.
What changed
Schema additions:
get-media-buys-request.json—include_webhook_activity(bool, default false),webhook_activity_limit(int 1–200, default 50)get-media-buys-response.json—webhook_activity[](maxItems 200, items$refthe canonical record)/schemas/core/webhook-activity-record.json— canonical record shape (95 lines). Top-leveladditionalProperties: falsewithextescape — deliberate departure from convention to enforce uniform-across-resources at the schema level. Nullable fields use draft-07 union types./schemas/core/truncation-sentinel.json— universal_meta.truncatedsentinel (42 lines). No field consumes it today; lands for futureinclude_webhook_payloadsextension. Openpreview_format,_meta.additionalProperties: truefor forward-compat.Normative resolutions (resolves #4278 RFC open questions):
completed_atuniformly acrosssuccess/failed/timeout/connection_error. Pending records run fromfired_atuntil termination, then transition. Sellers unable to honor MUST omit the field entirely via the three-state semantics.[]= persists but no recent fires; non-empty = actual records. Buyers diagnose omissions viapush_notification_configregistration state +propagation_surfacescapability declaration.idempotency_keynotdelivery_id— equals the webhook payload's dedup key, so buyers correlate to their endpoint logs without a join table.pending(notretrying— outcome-noun vocabulary).timeout/connection_errorsplit is operationally distinct and intentional.error_message— server-side classification only; never request/response bodies or headers; reserved for futureinclude_webhook_payloads.subscriber_idreserved on the canonical record for multi-subscriber configurations (precedent: 4.0: multi-subscriber reporting_webhook on media buys #3009).Documentation:
docs/protocol/snapshot-and-log.mdx— new normative § Webhook activity log pattern with a 7-item adoption checklist so future consumers (Creative lifecycle webhooks: formalize state-change notifications outside media buy lifecycle #2261 creative lifecycle, RFC: Webhook foundation for AdCP (epic) #4582 track 3 per-account reads in 3.2) have unambiguous MUST hooks.docs/building/by-layer/L3/webhooks.mdx— "Diagnosing missing fires" subsection back-linking to the call site.docs/media-buy/task-reference/get_media_buys.mdx— full call-site documentation, status semantics, three-state presence, retention rule, privacy rules, omission diagnosis, JS/TS + Python example using the canonicalWebhookActivityRecordtype that the SDK regenerates from/schemas/core/.#4582epic scopeReview history
Two parallel expert review passes (
dx-expert+ad-tech-protocol-expert) on the basic #4278 surface, then a second parallel pass on the track-4 generalization, then a final consistency check (code-reviewer) before commit. Fixes applied across:delivery_id→idempotency_key(eliminates join-table footgun)status: retrying→status: pending(outcome-noun consistency with Stripe/Segment)notification_typekeeps full enum incl.impairment(same debug contract for delivery + health fires)additionalProperties: falseon record items (stable contract, no smuggling)subscriber_idframing portable across resources (not media-buy-specific)fired_at→completed_atwithpendingcarve-out (retries don't age out mid-trail)preview_formatopened from closed enum → open string with five common values_meta.additionalProperties: truefor sentinel forward-compatWebhookActivityRecordshown via type import in examples; SDK regen-required notedmaxItems: 200on response array (structural enforcement of the request cap)nullable: true(OpenAPI shorthand) replaced with draft-07 union types ("type": ["string", "null"])timeout/connection_erroroutcomes explicitlyTest plan
npm run build:schemas— clean, $ref resolves in bundled outputnpm run test:schemas— 7/7npm run test:composed— 43/43npm run test:json-schema— 260/260npm run test:examples— 36/36@adcp/sdkand verifyWebhookActivityRecordexports as expected before merge — affects only the example imports, not the spec correctness🤖 Generated with Claude Code