Skip to content

feat: add marketing consent extension for per-channel opt-in#407

Open
wsbrunson wants to merge 2 commits intoUniversal-Commerce-Protocol:mainfrom
wsbrunson:feat/marketing-consent-extension
Open

feat: add marketing consent extension for per-channel opt-in#407
wsbrunson wants to merge 2 commits intoUniversal-Commerce-Protocol:mainfrom
wsbrunson:feat/marketing-consent-extension

Conversation

@wsbrunson
Copy link
Copy Markdown

@wsbrunson wsbrunson commented May 1, 2026

Description

Note: The original draft of this PR proposed Alternative Option 1: Separate marketing_consent extension as the recommended approach. The description below reflects an updated proposal based on feedback from @jamesandersen.

Evolves the existing buyer_consent extension with per-channel marketing consent capture. Rather than introducing a separate extension, this adds two fields to buyer_consent:

  • marketing_consent_options (checkout level, business → platform): An array of marketing channels the business offers for opt-in, each with a channel identifier, display text, and privacy policy URL. Included in create and update checkout responses only.
  • marketing_channels (on buyer.consent, platform → business): An array of the buyer's per-channel opt-in decisions, submitted at checkout completion.

The marketing boolean on buyer.consent is deprecated but not removed. When the business includes marketing_consent_options in the checkout response, platforms MUST use marketing_channels instead of marketing.

Examples

Business-Requested Consent (Checkout Response)

{
  "id": "checkout_789",
  "status": "ready_for_complete",
  "currency": "USD",
  "buyer": {
    "email": "jane@example.com",
    "consent": {
      "analytics": true,
      "preferences": true,
      "sale_of_data": false
    }
  },
  "marketing_consent_options": [
    {
      "channel": "email",
      "display_text": "Promotional emails and exclusive offers",
      "privacy_policy_url": "https://example.com/privacy"
    },
    {
      "channel": "sms",
      "display_text": "Order updates and deals via text",
      "privacy_policy_url": "https://example.com/privacy"
    }
  ],
  "line_items": [...],
  "totals": [...]
}

Consent Capture (Complete Request)

{
  "buyer": {
    "consent": {
      "analytics": true,
      "preferences": true,
      "sale_of_data": false,
      "marketing_channels": [
        { "channel": "email", "opted_in": true },
        { "channel": "sms", "opted_in": false }
      ]
    }
  },
  "payment": {
    "handler": "dev.ucp.payments.example",
    "details": { "token": "tok_abc123" }
  }
}

Motivation

Marketing opt-in is a standard feature on most e-commerce checkout pages. The existing buyer_consent extension includes a marketing boolean, but this does not cover more advanced use cases for collecting marketing consent:

  • Businesses need per-channel control: Email and SMS consent are legally distinct and require separate opt-in. CAN-SPAM treats email as opt-out while the TCPA requires prior express written consent for SMS, with statutory damages of $500–$1,500 per message.
  • Businesses need to declare what consent they want: The current boolean only flows from platform to business. There is no mechanism for a business to request consent collection.
  • Regulatory compliance: GDPR, CAN-SPAM, and CCPA require explicit, channel-specific consent with a clear privacy policy reference.

Category

  • Capability: New schemas (Discovery, Cart, etc.) or extensions. (Requires Maintainer approval)
  • Documentation: Updates to README, or documentations regarding schema or capabilities. (Requires Maintainer approval)

Checklist

  • I have followed the Contributing Guide.
  • I have updated the documentation (if applicable).
  • My changes pass all local linting and formatting checks.
  • (For Core/Capability) I have included/updated the relevant JSON schemas.

Alternative Approaches

Option 1: Separate marketing_consent Extension

The original proposal in this PR. Creates a new dev.ucp.shopping.marketing_consent extension with a marketing_consent object on the buyer containing options and consents.

Details

This approach adds a new extension schema (marketing_consent.json) and documentation page (marketing-consent.md) alongside the existing buyer_consent extension.

The extension adds a marketing_consent object to the buyer within checkout, containing:

  • options (business → platform): An array of marketing channels the business offers for opt-in.
  • consents (platform → business): An array of the buyer's opt-in decisions per channel.

It supports two flows:

  1. Platform collected consent: The platform sends marketing_consent.consents on create/update.
  2. Business-requested consent: The business returns marketing_consent.options in the checkout response, the platform collects consent and sends it on complete.

Why we moved away from this: Two extensions composing onto the same buyer object via allOf creates schema resolution complexity. A normative rule that marketing_consent supersedes buyer.consent.marketing adds fragmentation. Evolving the existing extension is cleaner.

Examples

Business-Requested Consent (Checkout Response):

{
  "buyer": {
    "email": "jane@example.com",
    "marketing_consent": {
      "options": [
        {
          "channel": "email",
          "display_text": "Promotional emails and exclusive offers",
          "privacy_policy_url": "https://example.com/privacy"
        },
        {
          "channel": "sms",
          "display_text": "Order updates and deals via text",
          "privacy_policy_url": "https://example.com/privacy"
        }
      ]
    }
  }
}

Consent Capture (Complete Request):

{
  "buyer": {
    "marketing_consent": {
      "consents": [
        { "channel": "email", "opted_in": true },
        { "channel": "sms", "opted_in": false }
      ]
    }
  }
}

Option 2: Breaking Change to Buyer Consent Object

Replace marketing: boolean with marketing: marketing_channel_consent on the existing consent object. Cleaner than deprecation but introduces a breaking change.

Details

The reason we decided against this was that there are multiple marketing consent properties we want to add and it is not clear whether any of them would apply to the other types of consents (analytics, sale_of_data).

If this kind of divergence is acceptable, we could propose a breaking change to the Buyer Consent extension and change marketing: boolean to marketing: marketing_channel_consent.

Option 3: Add marketing_consent Field to Existing Buyer Consent

Keep marketing: boolean and add a separate marketing_consent object under buyer or consent. Non-breaking but introduces two sources of truth.

Details

For this option, we would keep:

{
  "buyer": {
    "consent": {
      "marketing": true
    }
  }
}

And then add either under buyer or consent the marketing_consent object with options. This would probably be the least amount of changes and would not be a breaking change, but it would introduce two separate sources of truth for marketing consent. There would still be the marketing: boolean property and there would be marketing_consent: { consents: [...] }, which could conflict with the top-level boolean if not kept in sync by the platform.

One could argue that this is technically true in the current proposal but we believe that including a specific extension for marketing_consent is strong enough signal to the Platform and Business that the buyer.consent.marketing field should be ignored.

If documented correctly, this could be a viable option, but we would only consider it if the governing body was okay with the complexity introduced.

Option 4: Generic Advanced Consent Extension

Create a broader buyer_consent_advanced extension that could extend any consent type, not just marketing.

Details

Instead of making a marketing-specific change, we could create a more generic advanced consent extension. The problem with this approach is that we are not sure how the other types of consents would be extended with more advanced options. We currently only see a use case for marketing, but if there are additional thoughts on this, we could explore this option.

@wsbrunson wsbrunson requested review from a team as code owners May 1, 2026 15:56
@google-cla
Copy link
Copy Markdown

google-cla Bot commented May 1, 2026

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

@wsbrunson wsbrunson force-pushed the feat/marketing-consent-extension branch 2 times, most recently from 3cbc98a to 7e690b8 Compare May 1, 2026 16:04
@wsbrunson
Copy link
Copy Markdown
Author

@jamesandersen wanted to make you aware of this feature request

@jamesandersen
Copy link
Copy Markdown
Contributor

Great work on this @wsbrunson — this is needed. The regulatory divergence between channels is real and growing: CAN-SPAM treats email as opt-out while the TCPA requires prior express written consent for SMS, with statutory damages of $500–$1,500 per message. A merchant receiving today's marketing: true might be legally safe to email but would face serious TCPA exposure if they sent an SMS based on that same boolean. Per-channel consent isn't a nice-to-have — it's a compliance necessity, and this PR addresses that head on.

This capability has been on my TODO list as well, so I've had a few design ideas brewing. I realize this comment has a lot of feedback — happy to keep iterating here, or I can throw up a counter-proposal PR if that's easier to compare.

1. Extend buyer_consent rather than creating a new extension

I think this should evolve the existing buyer_consent extension rather than introduce a parallel marketing_consent extension. The per-channel consent entries would live at buyer.consent.marketing_channels, with buyer.consent.marketing (boolean) going on a deprecation path:

{
  "buyer": {
    "consent": {
      "analytics": true,
      "preferences": true,
      "sale_of_data": false,
      "marketing_channels": [
        { "channel": "email", "opted_in": true },
        { "channel": "sms", "opted_in": false }
      ]
    }
  }
}

Why:

  • One extension, one capability: Consent is consent. Having dev.ucp.shopping.buyer_consent and dev.ucp.shopping.marketing_consent as separate capabilities that both govern marketing consent creates fragmentation — platforms and businesses need to negotiate and implement two overlapping extensions.
  • No superseding rule needed: The current proposal requires a normative rule that marketing_consent supersedes buyer.consent.marketing when both are advertised. If marketing_channels lives inside buyer.consent, the precedence is local and simple: when marketing_channels is present, marketing (boolean) is ignored.
  • Schema resolution: Both the existing buyer_consent extension and this new marketing_consent extension compose onto the buyer object via allOf. When both are active, the platform must resolve two independent extensions claiming composition over the same checkout property. Keeping everything in buyer_consent avoids this conflict — one extension, one composition, one set of ucp_request annotations for buyer.
  • Clean deprecation path: buyer.consent.marketing (boolean) is deprecated in-place. Existing implementations that only send the boolean continue to work. New implementations adopt marketing_channels. No need to manage two independent extension lifecycles.

2. Business-declared options belong on checkout, not buyer

The business's available marketing channels are a property of the business/checkout, not the buyer. I'd suggest placing the options array at the checkout level (e.g., checkout.marketing_consent_options) rather than under buyer.marketing_consent.options. The buyer's consent decisions belong under buyer.consent.marketing_channels, but what the business offers is business context — similar to how available_payment_instruments lives at the checkout level, not under buyer.

Additionally, normative guidance should specify that businesses SHOULD NOT include options for channels where they already have the buyer's consent (e.g., from a prior purchase, website interaction, or identity-linked account). This keeps the protocol stateless — the platform doesn't need to track subscription state — and it eliminates ambiguity for buyers: if a returning subscriber is presented with a consent prompt and doesn't check the box, does that revoke their existing subscription? The cleanest answer is to not present the prompt in the first place.

3. Channel enum — closed with whatsapp

I'd advocate for keeping a closed enum but adding whatsapp to the initial set. A closed enum is the right call here because both the business and platform need to exchange matching values — an open string invites fragmentation (Email vs e-mail vs email, SMS vs sms vs text). With a closed enum, validation catches mismatches at schema time rather than in production.

That said, the current set of ["email", "sms"] is too narrow. WhatsApp is a primary merchant-to-buyer communication channel in LatAm, India, and Southeast Asia. Starting with ["email", "sms", "whatsapp"] avoids excluding a large portion of the global commerce market from day one. Future channels (RCS, push, etc.) can be added via spec updates — and arguably should require a spec update, since new channels typically bring new regulatory requirements that deserve review.

4. Consent at complete only

I'd suggest dropping the "platform collected consent" flow (consent on create/update) and restricting consent capture to complete only. The business-requested flow is the stronger design — the business declares options in the checkout response, the platform renders consent UI, and the buyer's decisions are submitted at completion alongside payment.

The regulatory argument for this is straightforward: GDPR requires consent to be "freely given" and the TCPA requires consent tied to a concrete action. Consent collected before the buyer commits to a purchase (e.g., at checkout creation) may not meet these bars. Tying consent to the "Place Order" action produces the strongest compliance record.

This also simplifies the extension — one flow, one direction, one timing.

5. buyer on complete

The base buyer_consent extension currently marks buyer as "complete": "omit", which blocks sending any consent at complete time — including the existing booleans (analytics, sale_of_data). If we evolve buyer_consent to support marketing_channels at complete, this is the right moment to change buyer on complete from "omit" to "optional" across the extension. This unblocks consent capture at the point of purchase for all consent types, not just marketing.

This also has a practical efficiency benefit: if the platform has no other reason to update the checkout (no address changes, no shipping selection), it can skip the update call entirely and pass consent on complete — saving a network round-trip.


Hopefully the above helps get us moving toward a good discussion on this topic. Would love to hear your thoughts. @vixdug — wanted to flag this for your input as well, since we've noted marketing consent as an important seller feature.

@jingyli jingyli added this to the Working Draft milestone May 5, 2026
@wsbrunson
Copy link
Copy Markdown
Author

wsbrunson commented May 5, 2026

@jamesandersen First of all, thank you for the detailed feedback! I actually agree or mostly agree with all your points. I'll push up a new commit to address this feedback.

I'll go one-by-one below:

1. Extend buyer_consent rather than creating a new extension

Agreed - the first draft of this proposal was to recommend this option. I hesitated because I wasn't sure how introducing a breaking change would go. It really does make the most sense long-term. Having separate extensions for each type of consent is cumbersome on both Platforms and Businesses.

Your recommendation of deprecate marketing and add marketing_channels allows us to make the change we want to make without having to introduce a breaking change.

2. Business-declared options belong on checkout, not buyer

This makes a ton of sense, especially now that the proposal is moving marketing consent back into the buyer object.

3. Channel enum — closed with whatsapp

I don't disagree with this. I don't have a strong opinion either way, I'm happy to add this to the current proposal and if others have strong opinions we can continue the debate.

4. Consent at complete only

I agree that having just a single flow makes this much simpler to document. Business says what channels they want to capture consent for, Platform renders consent checkboxes, sends back opted_in values for anything checked.

I think in the future if we decide that we want the Platform to be able to collect consent and send it to the Business before the Buisness asks, or if we want to introduce collecting consent at the cart level, we could add them without introducing any breaking changes or interrupting the Business -> Platform flow.

5. buyer on complete

Definitely, I'll include that in my update.

}
```

### Deprecation: `marketing` Boolean
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jamesandersen included this section about deprecating the marketing boolean so that we don't have to make a breaking change. Let me know if you agree with the wording

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The deprecation wording reads well, but I think we should handle this at the schema level rather than as a docs section — using the transition annotation pattern established by prior UCP deprecations (#145, #203).

I'd suggest removing the ### Deprecation: marketing Boolean section from the docs and instead updating buyer_consent.json:

"marketing": {
  "type": "boolean",
  "description": "Deprecated. Use marketing_channels for per-channel consent.",
  "deprecated": true,
  "ucp_request": {
    "complete": {
      "transition": {
        "from": "optional",
        "to": "omit",
        "description": "Replaced by marketing_channels. Platforms should use marketing_channels when marketing_consent_options is present."
      }
    }
  }
}

This keeps the deprecation machine-readable and consistent with how the spec has handled deprecations before.

wsbrunson added 2 commits May 5, 2026 10:55
Adds dev.ucp.shopping.marketing_consent extension supporting two flows:
platform-collected consent and business-requested consent with per-channel
granularity (email, sms).
Extends the existing buyer_consent extension with per-channel marketing
consent capture instead of a separate extension. Adds marketing_consent_options
as a checkout-level field for business-declared channels, and
marketing_channels on buyer.consent for platform-submitted consent at
checkout completion. Deprecates the marketing boolean. Widens buyer on
complete from omit to optional.
@wsbrunson wsbrunson force-pushed the feat/marketing-consent-extension branch from 5ff77fa to 4af7636 Compare May 5, 2026 15:56
@igrigorik igrigorik self-requested a review May 8, 2026 16:16
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.

3 participants