Skip to content

feat(webhooks): webhook_activity[] on get_media_buys + standardized log surface (#4278, #4582 track 4)#4701

Merged
bokelley merged 4 commits into
mainfrom
bokelley/webhook-activity-get-media-buys
May 18, 2026
Merged

feat(webhooks): webhook_activity[] on get_media_buys + standardized log surface (#4278, #4582 track 4)#4701
bokelley merged 4 commits into
mainfrom
bokelley/webhook-activity-get-media-buys

Conversation

@bokelley
Copy link
Copy Markdown
Contributor

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 #4582 track 4 generalizes from this seed.

Closes #4278. Lands #4582 track 4.

What changed

Schema additions:

  • get-media-buys-request.jsoninclude_webhook_activity (bool, default false), webhook_activity_limit (int 1–200, default 50)
  • get-media-buys-response.jsonwebhook_activity[] (maxItems 200, items $ref the canonical record)
  • NEW /schemas/core/webhook-activity-record.json — canonical record shape (95 lines). Top-level additionalProperties: false with ext escape — deliberate departure from convention to enforce uniform-across-resources at the schema level. Nullable fields use draft-07 union types.
  • NEW /schemas/core/truncation-sentinel.json — universal _meta.truncated sentinel (42 lines). No field consumes it today; lands for future include_webhook_payloads extension. Open preview_format, _meta.additionalProperties: true for forward-compat.

Normative resolutions (resolves #4278 RFC open questions):

  • Retention is MUST 30 days from completed_at uniformly across success/failed/timeout/connection_error. Pending records run from fired_at until termination, then transition. Sellers unable to honor MUST omit the field entirely via the three-state semantics.
  • Three-state presence: omitted = seller does not surface; [] = persists but no recent fires; non-empty = actual records. Buyers diagnose omissions via push_notification_config registration state + propagation_surfaces capability declaration.
  • idempotency_key not delivery_id — equals the webhook payload's dedup key, so buyers correlate to their endpoint logs without a join table.
  • Status enum — 5 values incl. pending (not retrying — outcome-noun vocabulary). timeout / connection_error split is operationally distinct and intentional.
  • URL — query string and fragment MUST be stripped; secret-shaped path segments SHOULD be redacted.
  • error_message — server-side classification only; never request/response bodies or headers; reserved for future include_webhook_payloads.
  • subscriber_id reserved 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 canonical WebhookActivityRecord type that the SDK regenerates from /schemas/core/.

#4582 epic scope

Review 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_ididempotency_key (eliminates join-table footgun)
  • status: retryingstatus: pending (outcome-noun consistency with Stripe/Segment)
  • notification_type keeps full enum incl. impairment (same debug contract for delivery + health fires)
  • additionalProperties: false on record items (stable contract, no smuggling)
  • subscriber_id framing portable across resources (not media-buy-specific)
  • Retention pivot: fired_atcompleted_at with pending carve-out (retries don't age out mid-trail)
  • preview_format opened from closed enum → open string with five common values
  • _meta.additionalProperties: true for sentinel forward-compat
  • "MUST follow verbatim" → explicit 7-item adoption checklist
  • Three-state cardinality universal; omission causes resource-specific
  • WebhookActivityRecord shown via type import in examples; SDK regen-required noted
  • maxItems: 200 on response array (structural enforcement of the request cap)
  • All nullable: true (OpenAPI shorthand) replaced with draft-07 union types ("type": ["string", "null"])
  • TypeScript fence labeled correctly on the typed example
  • Retention rule clarified to cover timeout / connection_error outcomes explicitly

Test plan

  • npm run build:schemas — clean, $ref resolves in bundled output
  • npm run test:schemas — 7/7
  • npm run test:composed — 43/43
  • npm run test:json-schema — 260/260
  • npm run test:examples — 36/36
  • Precommit hooks (test:unit + typecheck) — clean
  • Visual review of rendered MDX (check the pattern doc cross-link, the call-site cross-link, the diagnosis paragraph)
  • Optional: rebuild @adcp/sdk and verify WebhookActivityRecord exports as expected before merge — affects only the example imports, not the spec correctness

🤖 Generated with Claude Code

…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>
@bokelley
Copy link
Copy Markdown
Contributor Author

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 push_notification_config; creatives genuinely can't (sync_creatives task webhooks are scoped to the sync operation, not creative-as-resource). Forcing track 3 through a real consumer is how we avoid the abstract-by-committee drift that #4588 successfully dodged.

#3009 composition still works — per-account anchor and multi-subscriber are orthogonal axes. Ship per-account-single-subscriber in 3.1 with subscriber_id already reserved on webhook_activity_record (this PR); #3009 adds the multi-subscriber dimension to either anchor later.

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.

bokelley and others added 3 commits May 17, 2026 22:16
…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>
@bokelley bokelley merged commit 9d056d3 into main May 18, 2026
18 checks passed
@bokelley bokelley deleted the bokelley/webhook-activity-get-media-buys branch May 18, 2026 07:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

RFC: Buyer-side webhook delivery visibility on get_media_buys

1 participant