Customer Insights - Journeys ·
Two cooks, one pot: rebuilding a double opt-in
A consent form and a cloud flow were writing to the same consent rows in undefined order. We stopped hardening the race and removed the second writer instead.
Ingredients
- The legal rule in one written sentence, before any code.
- Two test contacts — one brand new, one with existing subscriptions. The new one is where the interesting failures live.
- A way to read the raw submission payload. Guessing at the end state is not testing.
- Exactly one flow enabled on the trigger. Two flows on the same trigger both fire.
Two cooks are seasoning the same pot. Neither knows the other is there. The dish is sometimes perfect and sometimes inedible, and the only variable is which of them reached the pot last.
The rule we had to implement: a subscription may only be recorded as consent once the customer confirms it by email, while an unsubscribe takes effect immediately. Opting in is delayed, opting out is instant, and that asymmetry is the whole recipe. The platform disagrees — a ticked consent block on submit means Opted In, right now, in both directions. As far as the form is concerned, salt is salt.
Somebody has to reconcile those two positions. The question it took us three attempts to answer is who. Names and IDs below are placeholders; every failure is one we watched happen.
Two cooks, one pot
The first design is the one almost everybody builds. Let the form write whatever it writes, then have a cloud flow set the unconfirmed opt-ins back to Opted Out and send the confirmation email. Two writers on the same rows, in an order guaranteed by nothing.
| Case | What happens |
|---|---|
| A | The flow writes Opted Out, the platform’s delayed processing writes Opted In over the top. Unconfirmed consent, recorded as consent. |
| B | The row does not exist yet when the flow looks. No else branch, so the flow does nothing and reports nothing. Run status Succeeded. |
Case B is the one that hurts: a dashboard full of green runs, and the thing the flow existed to prevent happening anyway. A flow that finds nothing and has nowhere to say so is not a safeguard, it is a decoration.
Hidden is still served
Underneath the race sat a second, independent bug. To show each customer only the direction that applied to them, the script hid the other blocks. Hidden in the CSS sense — still in the DOM, therefore still in the payload. Hidden and unticked means an explicit Opted Out: a withdrawal of consent nobody declared. One test submission produced four of them, three from a single hidden container.
If a block must not influence the payload, remove it. Hiding is a display decision being asked to do a data job.
Take the second cook off the station
We spent a while making the race safer — better waits, a status check, an else branch so case B could at least complain. All real improvement, all still a race.
Then somebody asked the question that ended the discussion: why is the form writing consent at all? It cannot know whether a subscription is confirmed. Only the confirmation can. The form’s job is to collect a request, and requests are not consent.
So for anything needing confirmation, the form now transmits no consent value whatsoever. It shows its own checkboxes and writes a wish list into a field with no consent column behind it. The flow reads that list, subtracts what the customer already has, and fires one call to the confirmation form. It writes nothing.
Old · two writers
Correct only when the timing happens to be favourable.
- form submits ticked consent blocks
- platform writes Opted Indelayed, unpredictably
- flow writes Opted Outsame rows
- whichever landed last wins
New · one writer
The only route into a consent row is the customer clicking.
- form submits a wish listno consent values
- flow compares, sends one emailwrites nothing
- customer clicks the link
- platform writes the consent
One label for every jar
Form and flow share a single string, P=<purposeid>|T=<topicid>, with the topic left empty for a purpose-level consent. It appears at four stations — the host page, each form row, the submitted wish list, the flow’s comparison — and is copied at every one, never converted.
Because the key carries its own purpose, nothing has to look a GUID up: the flow puts the purpose in alongside every requested topic, so confirming sets both. That is why the flow knows no purposes, topics, brands or languages, and why a new topic costs zero flow changes.
The gate that is not a subscription
The platform greys out topic checkboxes while their parent purpose is unticked, so a purpose with topics under it has to stay ticked. It is a gate, not a subscription, and the script hides it visually.
Fine — except for the customer who has nothing yet. All their topic blocks were removed, so the gate guards an empty room, and ticking it submits a purpose-level opt-in nobody confirmed. The rule that closes it: if no topic block survives under a gate purpose, remove the gate too.
Measured afterwards, a brand new customer transmits zero consent values. Not few. Zero.
Two details worth stealing
The flow waits for the processed status and the email field, subtracts anything already opted in, and terminates green on an empty wish list — the common case, which has to be cheap. Two things in there we did not get right first time:
- Frame the keys before comparing them.
P=x|T=is a prefix ofP=x|T=y, so acontainscheck matches when it must not and a customer’s request disappears without an error. Wrap each key in brackets. - Guard the empty string before parsing it. The wish list arrives present but empty whenever nobody ticked anything, which is most submissions. Parsing it throws, and that throw sends the single most common path into the catch block.
The wait loop that nearly took it down
The old flow waited for the processed status and for consent values to arrive. Sensible: wait for the ingredients before cooking. Now carry that loop into a design whose entire point is that no consent values arrive. The condition can never be satisfied, so every signup fails — by hanging rather than erroring, which is the least visible way available.
When you remove something from a system, go and find everything that was waiting for it. A wait condition is a dependency that appears in no dependency list.
Still on the stove
- The unsubscribe direction is deliberately not rebuilt. Until it is, unsubscribing runs through the real consent block that stays on the form for subscribers, where an untick takes effect immediately.
- The confirmation form ID differs per environment and sits hard-coded in the markup, so every deployment needs a manual step. Resolving the form by a stable name would end that: humans keep names consistent across environments, GUIDs are not even trying.
- Two submissions in a row produce two emails. De-duplication compares against existing state, and an unconfirmed request exists nowhere by design. Duplicate email, not wrong consent — we priced it and left it.
The recipe card
- Find the asymmetry first. Ours was one sentence, and every structural decision below it was forced by that sentence.
- Two writers on one row is not a timing problem. Ask which one has no business writing there, and remove it. A race you have eliminated needs no tests.
- Hidden is submitted, removed is not. Anything carrying a value that must not count has to leave the DOM.
- When you remove something, hunt down whatever was waiting for it. It will not error. It will hang.
The fifth attempt was not better than the fourth because we got smarter. It was one question, asked late: why is this component writing here at all?
At the table
Loading comments…