Skip to content

0045. Write Positive User consent over the documented consent flow

Date: 2026-10-05 Amended: 2026-10-07 Status: Accepted

Context

Consent has to travel into Positive User too: a customer who subscribes or withdraws in a connected shop must reach the contact Conflux created for them (ADR-0044).

The first outbound version was a single field write. A dedicated consent writer set the core unsubscribed suppression flag through mass_update_standard_attributes and touched nothing else. The reasoning came from CFX-213, which rules that Positive User's marketing consent fields "belong to a separate double opt-in flow and must not be written from a data sync, so its writer maps only the standard unsubscribed field".

That met the acceptance criterion that a withdrawal always propagates. It failed the one above it, that a consent change in a connected shop propagates back to Positive User. A shop's confirmed opt-in arrived as "not suppressed", which is not the same statement. email_opt_in stayed unset, so every automation gated on it still treated the contact as having given nothing.

User.com documents a consent flow for exactly this (Marketing Consents). It is a field write followed by the event that automations trigger on, and it distinguishes two cases:

  • Verified consent, which the caller has already confirmed: write the consent field, then fire consent_granted.
  • Unverified consent, which it has not: write no consent field at all, and fire consent_requested carrying requested_email_opt_in. Positive User sends its own double opt-in mail and sets the field itself once the contact confirms.

The second case resolves the apparent conflict with CFX-213. An unconfirmed consent never writes the marketing field. It asks Positive User to run the flow that owns the field. What CFX-213 protects against is setting consent the source has not verified. It does not forbid speaking to the consent fields at all.

The CFX-180 branch carried this flow on every customer update and needed a memory table of its own, user_platform_consent_deliveries, so that an address change would not fire consent_requested again. ADR-0041 then made consent a dependent domain with its own rule, its own change signal and its own ledger rows. This record replaces that branch's version of the decision for the new model.

Decision

One writer, two domains. The flow lives in UserPlatformOutboundWriter, the writer that creates and updates the contact. It does not get a writer of its own. OutboundWriterRegistry holds one writer per driver, and a contact and its consent are one record in the workspace: two writers for one target would each think they own it. The writer carries customers and customer_consents. For the second domain it is only ever asked for an update(), with the anchor's contact id as the remote id (ADR-0042), and its create() refuses a consent outright.

The flow, per consent state. UserPlatformConsentMapper::callsFor() sorts one email ConsentData into its calls:

subscribed confirmed Calls, in this order
true true PUT users/{id}/ {"email_opt_in": true}; mass update unsubscribed: false; event consent_granted {"email_opt_in": true, "source": …}
true false or null event consent_requested {"requested_email_opt_in": true, "source": …} only
false any PUT users/{id}/ {"email_opt_in": false}; mass update unsubscribed: true; event consent_granted {"email_opt_in": false, "source": …}
null any nothing: refused by accepts()

A withdrawal is verified on its own, whatever confirmed says: confirmed describes an opt-in, and an opt-out needs no confirmation to be honoured. A shop reports confirmed: null, so a shop subscription asks Positive User for the double opt-in, which is the intent.

unsubscribed moves alongside the field. It is the contact's email suppression flag. It speaks for email and for no other channel, so the two fields are two halves of one statement. Neither is redundant: email_opt_in is what an automation reads, while unsubscribed is what the app's own sending honours. A withdrawal that set only the first would leave the contact mailable. PUT users/{id}/ does not accept unsubscribed, which is why the flag goes through PUT users/mass_update_standard_attributes/ with {"ids": [<int>], "attribute": "unsubscribed", "value": <bool>}. Where Conflux only asks (consent_requested), neither field is touched.

The event's source is the consent's sourceSystem value (woocommerce, prestashop), the nearest honest equivalent to the form names User.com's examples use. Event data carries only the email channel, the one the consent speaks for.

Order and failure. The requests go out in the table's order on the anchor id. A step that fails throws (OutboundWriteException, TargetRateLimitedException), and the retry runs the whole flow again from the start. Only the first request that goes out may report the contact gone: a 404 in the app's own shape there is a RemoteRecordGoneException, and the consent row becomes remote_gone without the suppression flag or the event going out. A later step addresses the contact the first one just reached, so a 404 there stays an ordinary, retryable failure.

Refused before any request. accepts() is false for a channel outside supportedConsentChannels() and for subscribed === null. Delivery records such a candidate as suppressed, with no request and no budget token. The writer keeps its own guard behind it.

Email only, at first. The writer and the integration declared [ConsentChannel::Email], and SMS was not carried. Since 2026-10-07 they declare [ConsentChannel::Email, ConsentChannel::Sms]: SMS travels as the suppression flag unsubscribed_from_sms plus the same events. General stays refused. See What a live app changed and SMS, as a suppression flag.

No custom_id, no update_or_create. Every call addresses User.com's numeric id from the ledger. No users-by-id/{custom_id} endpoint is used.

What a live app changed about this

User.com's documentation describes marketing consent as custom attributes named email_consent and sms_consent. A live app defines no custom attributes at all (GET /api/public/attributes/ answered count: 0). It exposes a core email_opt_in field instead, which carries the same signal in the three states this decision needs: granted, withdrawn, and never asked.

The decision survives that intact. The split between writing a verified consent and requesting an unverified one is about what Conflux may assert, not about which field holds it. What changed is the field, and one consequence of it: the contact object has no sms_opt_in, so SMS consent has nowhere to go. It is refused rather than written onto a field that means something else. Where SMS consent lives, if anywhere, is unsettled.

That last point was settled on 2026-10-07, against the same staging workspace. OPTIONS users/{id}/ lists a standard boolean attribute unsubscribed_from_sms among the fields the contact endpoint accepts, next to whatsapp_unsubscribed. The app validates it: PUT users/{id}/ {"unsubscribed_from_sms": true} answers 200, a non-boolean value answers 400 "Must be a valid boolean.", and mass_update_standard_attributes accepts it as an attribute. (A plain PUT ignores a field it does not know with a 200, so the 400 is what proves the field is real.) The change was confirmed in the User.com UI: the contact's SMS unsubscribe moved after the PUT. Two limits came with it. There is still no SMS opt-in, neither sms_opt_in nor a custom sms_consent, only this suppression flag. And the flag is write-only through the API: GET users/{id}/ in API versions 1, 2 and 3, the list and the search never return it, and the search answers "Unknown client attribute".

SMS, as a suppression flag

SMS consent is carried from that date on, mirroring the email flow with the suppression flag as its only field:

subscribed confirmed Calls, in this order
true true PUT users/{id}/ {"unsubscribed_from_sms": false}; event consent_granted {"sms_consent": true, "source": …}
true false or null event consent_requested {"requested_sms_consent": true, "source": …} only
false any PUT users/{id}/ {"unsubscribed_from_sms": true}; event consent_granted {"sms_consent": false, "source": …}
null any nothing: refused by accepts()
  • The flag travels with the contact write, not through the mass update unsubscribed needs. The contact endpoint accepts it, which keeps the SMS flow at two requests, keeps UserPlatformConsentCalls split by endpoint (fields is what PUT users/{id}/ takes, unsubscribed is the one attribute it does not), and makes the first request one that answers 404 for a gone contact.
  • The event key is sms_consent, User.com's documented name. Email's key is its field, email_opt_in; SMS has no opt-in field to name. The request event prefixes it with requested_ either way.
  • One channel never moves the other. An SMS consent touches neither email_opt_in nor unsubscribed, and an email consent never touches unsubscribed_from_sms.
  • Inbound, SMS arrives over the webhook only. The pull cannot read the flag and stays email-only. The automation's payload may carry unsubscribed_from_sms: true is an SMS withdrawal, while false is the default every contact carries and is read as nothing, the same asymmetry unsubscribed gets on the email side. An absent or unreadable flag is nothing too.
  • No shop produces SMS consent today. WooCommerce and PrestaShop emit email consent only, and their writers declare email only. The outbound SMS path waits for a source that produces SMS consent. An SMS withdrawal reported by the webhook lands in the Positive User mirror; a rule that would carry it on to a shop finds the shop writer refusing the channel.
  • The SMS mirror is one-way. The Positive User connection's SMS row is only ever written as a withdrawal, and nothing clears it: a later subscription, whether set in the app or written by Conflux, is never reported back. The row is the last withdrawal seen, not the contact's current state. Taken as the current state, it breaks echo detection (ADR-0043). After a shop's confirmed SMS opt-in cleared the flag, the mirror would still read "withdrawn", and the shop's next withdrawal would look like an echo of it and never be sent. So an integration declares the channels it reports in full through HasFullyReportedConsentChannels. Positive User declares [Email], and the shops declare [Email]. CustomerConsent::isFullyReportedBy() answers the question for a row. DependentEchoes treats a target mirror row its integration does not report in full as absent, so the record is delivered, with no echo check and no two-way conflict. An integration that declares nothing is taken to report every channel in full, as before. Email is unaffected. The admin consent view marks such a row "Last reported, not live" and leaves it out of the "Differs" comparison.
  • An SMS confirmation never comes back. consent_requested is still fired for an unconfirmed SMS subscription, because an automation may run its own SMS double opt-in on it. All a confirmation can do on the contact is clear unsubscribed_from_sms, and the webhook reads false as nothing, so Conflux never learns of it.

Consequences

Up to three requests per delivery. A verified email consent or withdrawal is the field write, the suppression flag and the event; a verified SMS one is the field write and the event. An unconfirmed subscription is one event. The delivery budget takes one token per writer call while User.com counts every request, so a burst can meet a 429 the budget would have avoided. ADR-0036 handles it. A per-call request cost is the lever if 429s show up in traces.

No memory table. The branch's user_platform_consent_deliveries is not needed. A consent is now delivered only when it changed: its own change signal moves only on a value change, and the echo check and the ledger's version comparison (ADR-0043) drop the rest. An address change moves the customer, not the consent, and so does not fire consent_requested again. Neither does the confirmation Positive User reports back through the webhook and the return rule.

A retry repeats an event. The flow can fail halfway: the field write lands and the event does not. There is no transaction to roll back, and none is wanted. Every call is idempotent, so a retry rewrites the same fields and fires the same event again. What a retry cannot undo is a duplicate event in the contact's timeline, which an automation that counts events rather than reading state would see twice.

Events need a listening automation. consent_requested and consent_granted are automation triggers. They land in the contact's event timeline either way, but they only cause anything, a double opt-in mail in particular, if the app has an automation listening for that event name. In an app without one the field write and the suppression flag still land, and the requested consent goes nowhere. This is a per-app configuration dependency Conflux cannot verify over the API. It has to be settled with whoever configures the Positive User apps.

consent_granted doubles as the withdrawal. User.com documents no revocation event, and its own example payload carries a false flag inside a consent_granted event: the event reports a state. An automation must therefore branch on email_opt_in or sms_consent in data, not on the event name alone. The key in data is also what tells an email event from an SMS one.

No custom_id, no update_or_create. update_or_create is the endpoint User.com documents for the field write, and it is not used. It is keyed by custom_id and creates a contact where none exists, which a consent write must never do (ADR-0031). Addressing the numeric id removes that outcome instead of guarding it: PUT users/{id}/ cannot create the contact it does not find. It answers 404, which is the one failure delivery does not retry. The id is the one the ledger already holds for a contact Conflux created, so nothing has to be resolved to use it.

A new contact reads "never asked" for a while. The contact is created without consent. The consent rule, made due by the create, delivers the consent in its next chain, in the same dispatch tick or the next one. Meanwhile the contact sends no marketing. An automation in the workspace that reads the opt-in the moment a contact appears would see it unset, which Conflux cannot check, as with the other automations above. The alternative, a customer writer that takes the due consents along on create, would bring back the coupling ADR-0041 removed.

A contact the customers backfill creates stays "never asked". When a workspace is connected to a shop that already has customers, the customers rule's first-activation backfill creates a contact for each of them. Those creates do not make the consents due (ADR-0042): a consent reaches such a contact once it changes in the shop, or through the guarded backfill. Anything else would ask every existing subscriber for a double opt-in, by mail, at the moment of connecting, and send a consent_granted withdrawal event for every customer who never subscribed. A consent that changed after the connection and was waiting for its contact is still delivered.