feat: add marketing consent extension for per-channel opt-in#407
feat: add marketing consent extension for per-channel opt-in#407wsbrunson wants to merge 4 commits into
Conversation
|
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. |
3cbc98a to
7e690b8
Compare
|
@jamesandersen wanted to make you aware of this feature request |
|
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 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
|
|
@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
|
5ff77fa to
4af7636
Compare
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.
5628760 to
1a70603
Compare
Replaces the docs-only deprecation section with a schema-level deprecated flag and transition annotation on the marketing boolean, per reviewer feedback.
1a70603 to
182aeef
Compare
|
@wsbrunson thanks for all the updates here! I'll take another pass soon but this is looking much closer to what I think makes senses for both platforms and businesses at the protocol level. I'll also be soliciting some more eyes on this to help make progress |
| "properties": { | ||
| "channel": { | ||
| "type": "string", | ||
| "enum": ["email", "sms", "whatsapp"], |
There was a problem hiding this comment.
Consider using an open string vocabulary instead here and at line 43 below — enum makes adding future channels (RCS, LINE, push) a schema-breaking change, which UCP tries to avoid. The spec itself says "new channels may be added as non-breaking changes," which directly contradicts using enum.
Fix:
"channel": {
"type": "string",
"description": "Marketing contact channel.",
"examples": ["email", "sms", "whatsapp"]
}
How about this alternate design?The protocol need this PR addresses is real and well-motivated. However, the current schema shape introduces a new pattern (two parallel arrays correlated by a shared key, sitting at different paths in the checkout) that doesn't appear elsewhere in UCP. That novel shape may cause some friction points in the schema — the closed UCP has two established patterns for "options + decision" flows that, composed together, give a cleaner shape: the cross-actor flow direction of How "options + decision" can be modeled in UCPThere are two established patterns. Marketing consent doesn't map cleanly to either on its own — it sits between them — but the right shape is probably a natural composition. Pattern A — cross-actor, external ID reference (single-select){
"id": "group_1",
"options": [
{ "id": "opt_standard", "title": "Standard Shipping", ... },
{ "id": "opt_express", "title": "Express Shipping", ... }
],
"selected_option_id": "opt_express"
}Business offers Pattern B — same-actor, per-row inline state (multi-select)
"instruments": [
{ "id": "card_1", "type": "card", "display": {...}, "selected": true },
{ "id": "card_2", "type": "card", "display": {...}, "selected": false }
]One array, per-row inline The marketing-consent caseMarketing consent needs Pattern A's actor flow (business offers, platform decides) and Pattern B's per-row inline state (independent yes/no per channel, with "shown and declined" distinguishable from "not shown"). Neither pattern alone fits; composing them could be a potentially cleaner option. The proposed shape below is that composition: Proposed shape// source/schemas/shopping/buyer_consent.json
"$defs": {
"marketing_consent_option": {
"type": "object",
"description": "A marketing channel offered by the business. Carries the buyer's opt-in decision when sent on the complete request.",
"required": ["channel", "display_text", "privacy_policy_url"],
"properties": {
"channel": {
"type": "string",
"description": "Marketing channel identifier. Clients MUST tolerate unknown values. Well-known: `email` (resolves to buyer.email), `sms` (resolves to buyer.phone_number), `whatsapp` (resolves to buyer.phone_number).",
"examples": ["email", "sms", "whatsapp"]
},
"display_text": {
"type": "string",
"description": "Human-readable description of what the buyer is consenting to receive."
},
"privacy_policy_url": {
"type": "string",
"format": "uri",
"pattern": "^https://",
"description": "HTTPS URL of the privacy policy governing this consent."
},
"opted_in": {
"type": "boolean",
"description": "Buyer's opt-in decision for this channel. Set by the platform on the complete request. Omit in business responses.",
"ucp_request": {
"create": "omit",
"update": "omit",
"complete": "required"
}
}
}
},
"dev.ucp.shopping.checkout": {
"title": "Checkout with Buyer Consent",
"allOf": [
{ "$ref": "checkout.json" },
{
"type": "object",
"properties": {
"buyer": {
"$ref": "#/$defs/buyer",
"ucp_request": {
"create": "optional",
"update": "optional",
"complete": "omit"
}
},
"marketing_consent": {
"type": "object",
"properties": {
"options": {
"type": "array",
"items": { "$ref": "#/$defs/marketing_consent_option" }
}
},
"ucp_request": {
"create": "omit",
"update": "omit",
"complete": "optional"
}
}
}
}
]
}
}Example flowCreate response (business → platform): {
"marketing_consent": {
"options": [
{ "channel": "email", "display_text": "Promotional emails", "privacy_policy_url": "https://example.com/privacy" },
{ "channel": "sms", "display_text": "Order updates via text", "privacy_policy_url": "https://example.com/privacy" }
]
}
}Complete request (platform → business): {
"marketing_consent": {
"options": [
{ "channel": "email", "display_text": "Promotional emails", "privacy_policy_url": "https://example.com/privacy", "opted_in": true },
{ "channel": "sms", "display_text": "Order updates via text", "privacy_policy_url": "https://example.com/privacy", "opted_in": false }
]
}
}The platform echoes back the options it actually displayed (with What this resolves
What do you think? |
|
Thanks @amithanda for the alternate design and rationale. A few thoughts: Hoisting out of
|
Description
Evolves the existing
buyer_consentextension with per-channel marketing consent capture. Rather than introducing a separate extension, this adds two fields tobuyer_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(onbuyer.consent, platform → business): An array of the buyer's per-channel opt-in decisions, submitted at checkout completion.The
marketingboolean onbuyer.consentis deprecated but not removed. When the business includesmarketing_consent_optionsin the checkout response, platforms MUST usemarketing_channelsinstead ofmarketing.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_consentextension includes amarketingboolean, but this does not cover more advanced use cases for collecting marketing consent:Category
Checklist
Alternative Approaches
Option 1: Separate
marketing_consentExtensionThe original proposal in this PR. Creates a new
dev.ucp.shopping.marketing_consentextension with amarketing_consentobject on the buyer containingoptionsandconsents.Details
This approach adds a new extension schema (
marketing_consent.json) and documentation page (marketing-consent.md) alongside the existingbuyer_consentextension.The extension adds a
marketing_consentobject to thebuyerwithin 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:
marketing_consent.consentson create/update.marketing_consent.optionsin the checkout response, the platform collects consent and sends it on complete.Why we moved away from this: Two extensions composing onto the same
buyerobject viaallOfcreates schema resolution complexity. A normative rule thatmarketing_consentsupersedesbuyer.consent.marketingadds 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: booleanwithmarketing: marketing_channel_consenton 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: booleantomarketing: marketing_channel_consent.Option 3: Add
marketing_consentField to Existing Buyer ConsentKeep
marketing: booleanand add a separatemarketing_consentobject underbuyerorconsent. Non-breaking but introduces two sources of truth.Details
For this option, we would keep:
{ "buyer": { "consent": { "marketing": true } } }And then add either under
buyerorconsentthemarketing_consentobject 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 themarketing: booleanproperty and there would bemarketing_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.marketingfield 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_advancedextension 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.