Skip to content

User Platform Outbound Writer

Reference for App\Integrations\UserPlatform\UserPlatformOutboundWriter, the first writer built against the Outbound Writer Contract that creates a record rather than only updating one. It puts a canonical CustomerData record into a Positive User (user.com) workspace as a contact, writes that contact's email and SMS consent onto it (see Consent), and puts a ProductData record into the workspace as a product (see Products). See ADR-0031 for why it creates its own contact rather than upserting one keyed by custom_id.

Classes

Class Holds
App\Integrations\UserPlatform\UserPlatformOutboundWriter The verbs, the endpoints, and how the target's answers read. Extends App\Services\AbstractOutboundWriter, driver SystemDriver::UserPlatform (user-platform)
App\Integrations\UserPlatform\UserPlatformContactMapper One domain: what a CustomerData looks like as a contact. Invokable, because it is one pure call and holds no state
App\Integrations\UserPlatform\UserPlatformProductMapper What a ProductData looks like as a product, and the custom_id it is created under. Invokable, with the workspace's attribute names handed in
App\Integrations\UserPlatform\UserPlatformProductSchema The attribute names a workspace's products already have, read off one product and cached
App\Integrations\UserPlatform\UserPlatformErrorMessage Turns an error response into the message a ledger row carries (of()), shared by the writer and the schema read
App\Integrations\UserPlatform\UserPlatformConsentMapper What a consent is on a contact, in both directions: callsFor() for this writer, fromContact()/fromWebhook() for the integration, and the channel list both declare
App\Integrations\UserPlatform\UserPlatformConsentCalls The calls one consent costs, as data: the fields to write, the suppression flag to move, and the events to fire

The split follows the domain. For records with a payload of their own, the writer names a domain in exactly two places. A consent has no payload or collection of its own: performUpdate() sends it down the consent flow before either is asked.

Method Answers For customers
payload(Projection) which mapping a record is put through UserPlatformContactMapper
collection(SystemCapability) which API collection that domain's records live in users/ (products/ for products)

Adding a domain costs a mapper class and one arm in each — no verb, no payload method on the writer, and nothing inherited from contacts about which verbs that domain's API offers. collection() is a match without a default arm, so a new SystemCapability is a static-analysis error here rather than a request to a path the writer invented; the one domain Conflux does not write to this target (orders) is refused by name. Deliberately not a resource interface with one implementation: what a second domain needs from this class is a path and a payload, and whether it has the same three verbs as a contact is a question about that domain's API, which a type built from this one would answer in advance.

payload() reads the record's type rather than the projection's domain, although the question is about the domain: Projection rejects a pair that disagrees on every construction path, so the two are the same question, and asking it this way also narrows the record for the mapper.

Declared verbs

Interface Declared Why
CanCreateRemoteRecords Yes Creates one contact or product per source record and keeps the numeric id the workspace assigns
CanUpdateRemoteRecords Yes Overwrites the contact or product addressed by that id with the full canonical payload
CanDeactivateRemoteRecords No The target has no inactive state of its own, for a contact or a product. Building one out of a custom attribute would mean changing somebody else's schema for a state they never asked for, and mapping it onto unsubscribed is ruled out by ADR-0016 — a record leaving a shop says nothing about what that person agreed to
CanDeleteRemoteRecords Yes Removes the contact or product addressed by that id. The API offers a hard delete only, so the record and everything the workspace recorded about it go with the call — whether a source deletion is answered with one is the rule's delete_policy, not this writer's decision
CanIngestRemoteRecords No The CSV bulk import returns no per-record identifier, so a batch taken through it would leave every contact unaddressable

The writer never searches the workspace for an existing contact and never decides between create and update itself — the delivery ledger has already answered that from a local row before either method is called (ADR-0015).

Endpoints

Verb Method Path Notes
Create POST /api/public/users/ Every field is optional; the response carries the numeric id the app assigned
Update PUT /api/public/users/{id}/ Addressed by the id the create returned; overwrites the full contact
Delete DELETE /api/public/users/{id}/ The same address, answered with 204. A hard delete — the only removal the app offers
Create a product POST /api/public/products-by-id/ Requires custom_id; the response carries the numeric id
Update a product PUT /api/public/products/{id}/ The full payload including the current custom_id
Delete a product DELETE /api/public/products/{id}/ A hard delete
Read the product schema GET /api/public/products/ Only the first result is used, see Products

Authentication and rate limiting

Fact Value
Header Authorization: Token <api_token>
Header Accept: */*; version=2 (selects the API version; not acceptJson(), since User.com does not accept a standard JSON Accept header)
Credential subdomain and api_token, read from SystemConnection::$credentials (encrypted)
Host https://<subdomain>.<USER_PLATFORM_API_HOST>/api/public/…, composed by UserPlatformClient::endpoint(). Never base_url, which is stored and checked against the subdomain but is not the host (ADR-0028)
Client Both headers and the host come from UserPlatformClient::deliveryRequest(), which also refuses a connection that is not entitled to be written to — the writer builds no request of its own
Rate limit 10 requests/second, published and enforced by User.com with HTTP 429
requestsPerMinute() 600

Field mapping

CustomerData → User.com contact payload, in UserPlatformContactMapper. A field the record does not carry is left out of the payload entirely, never sent as an explicit null — the target merges what it receives, and a null would clear a value the workspace filled in itself.

CustomerData field Payload field Notes
email email Trimmed; omitted if empty
firstName first_name Trimmed and truncated to 40 characters
lastName last_name Trimmed and truncated to 40 characters
phone phone_number Normalised to E.164 via App\Services\PhoneNumbers::toE164(), read against addressBilling->country, then addressShipping->country, then the region in sourceLocale (the source shop's own locale, established at connection verification). Each candidate is narrowed to ISO 3166-1 alpha-2 before it competes — a source country is not guaranteed to be a code, and PrestaShop fills one from its localized country-name map — so an unusable value falls through instead of shutting out the candidates behind it. Narrowing checks membership, not just shape: UK and EL look like codes and are no country the library knows, and accepting either would end the chain and then fail to parse
gender gender Unknown/Other → 1, Male → 2, Female → 3
tags tags Always sent as a list, including when it is empty — the one field exempt from the omit-rather-than-null rule above, because tags on a contact Conflux created are not a value the workspace owns and an omitted empty list would make the removal of a customer's last tag unrepresentable
customAttributes top-level keys Each CustomAttributeData is sent under its own key, alongside the core fields. One whose value is null is left out, under the omit-rather-than-null rule above — a shop's meta key with no value would otherwise clear whatever the workspace holds under that name
— conflux_source Always written: the source connection's name (sourceConnectionName), which CustomerProjector already fills from the connection's name or, where it has none, its base_url host — name is nullable and no handshake path sets one, so the host is what keeps two shops on the same platform apart in practice. The platform (sourceSystem->value, e.g. woocommerce) is the last resort, for a record that names no connection at all

Not yet reconciled with the Unified Data Model

This mapping was built against User.com's own API documentation, not against the Unified Data Model sheet. It still needs a pass to confirm every CustomerData field maps onto the field the sheet expects — treat the table above as provisional until that reconciliation happens.

What the payload deliberately does not write

Not written Reason
custom_id Belongs to the workspace's own bookkeeping; reading or writing it would mean matching against contacts Conflux never created (ADR-0031)
Any consent field (unsubscribed, email_opt_in, sms_opt_in) Consent is its own domain and goes out as a customer_consents update (Consent), never with the contact. The fields are reserved keys, so a custom attribute cannot set one either. Reusing a suppression flag for "record removed at source" would misrepresent a deletion as a person's objection
Fields the DTO does not carry id, key, company_custom_id, sms_opt_in, and anything else the payload shape does not model in CustomerData

The writer's second domain is customer_consents, a dependent domain (ADR-0041). A consent has no record of its own in the workspace. It is fields and events on its contact, so delivery only ever asks for an update(), with the anchor's contact id as the remote id (ADR-0042). The flow follows the one User.com documents for a backend integration (ADR-0045).

It is part of this writer rather than a second one because the registry holds one writer per driver, and the contact and its consent are one record. UserPlatformContactMapper sends no consent, and its reserved keys (email_opt_in, sms_opt_in, unsubscribed, unsubscribed_from_sms) keep a custom attribute from ever overwriting one. UserPlatformConsentMapper::callsFor() sorts a consent into a UserPlatformConsentCalls (fields, suppression flag, events), and the writer sends exactly those.

What accepts() refuses

Projection accepts()
customers true
customer_consents, channel email or sms, subscribed true or false true
customer_consents, a channel outside supportedConsentChannels() (general) false
customer_consents, subscribed: null false

A refused candidate is recorded suppressed before any request and before a budget token is taken; see accepts(). Writing an unknown subscription as false would unsubscribe the person. writeConsent() keeps the same guard as a second line, as the shop writers do. The check comes from the App\Services\Concerns\AcceptsConsentChannels trait. This writer renames it to acceptsConsent() and lets every customers projection through ahead of it.

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())

An SMS consent takes the same branches with unsubscribed_from_sms as its only field, the SMS suppression flag. The contact has no SMS opt-in field.

subscribed confirmed SMS 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())

An SMS consent never touches email_opt_in or unsubscribed, and an email consent never touches unsubscribed_from_sms. The SMS flag needs no mass update because the contact endpoint accepts it. No shop produces SMS consent today, so this path waits for a source that does.

A withdrawal is verified on its own: confirmed describes an opt-in, and an opt-out needs no confirmation to be honoured. An unconfirmed subscription writes no field at all. It asks Positive User to run its own double opt-in. A shop reports confirmed: null, so every shop subscription takes that branch.

For email, the confirmation comes back over the consent webhook: the double opt-in sets email_opt_in, the automation reports it, and the Positive User mirror holds the confirmed subscription. For SMS it never comes back. All a confirmation can do on the contact is clear unsubscribed_from_sms, and the webhook reads unsubscribed_from_sms: false as nothing, because it is every contact's default. consent_requested is still fired for an unconfirmed SMS subscription: an automation in the workspace may run its own SMS double opt-in on it, even though Conflux never hears the outcome.

source is the consent's sourceSystem value (woocommerce, prestashop).

Requests

Step Method Path Body
Field PUT /api/public/users/{id}/ {"email_opt_in": <bool>}, or for SMS {"unsubscribed_from_sms": <bool>}
Suppression (email only) PUT /api/public/users/mass_update_standard_attributes/ {"ids": [<int id>], "attribute": "unsubscribed", "value": <bool>}
Event POST /api/public/users/{id}/events/ {"name": "<event>", "data": {…}}

{id} is the anchor id the delivery layer hands over in the RemoteReference. Every request is built by UserPlatformClient::deliveryRequest(). No custom_id-keyed endpoint and no update_or_create is used.

Failure semantics

Outcome Result
The first request sent answers 404 in the app's own shape RemoteRecordGoneException; the consent row becomes remote_gone, and no later step goes out. For a confirmed or withdrawn state the first request is the field write; for an unconfirmed subscription it is the event
A later step answers 404 Ordinary OutboundWriteException, whatever the body: the first request just reached the contact, so the 404 says something went wrong in between, not that the contact is gone
Any step answers 429 TargetRateLimitedException, as for a contact write
Any step answers another non-2xx OutboundWriteException; the retry runs the whole flow again from the start
A create is asked for a consent UndeliverableRecordException. Delivery never asks; the guard keeps a consent out of the contacts collection

One delivery costs one token of the delivery budget, while User.com counts up to three requests. A burst can therefore meet a 429 the budget would have avoided, which the rate-limit handling catches (ADR-0036).

Why unsubscribed is written as well

It is not redundant with email_opt_in. That field is what an automation reads, while unsubscribed is what the app's own sending honours, so a withdrawal that set only the first would leave the contact mailable. PUT users/{id}/ does not accept unsubscribed; the mass update is the endpoint that moves it, selecting contacts by the same numeric id.

A retry repeats an event

There is no transaction across the three requests. Every call is idempotent, so a retry rewrites the same fields, but it also fires the event again. An automation that counts events rather than reading state sees it twice.

The events only do something if an automation listens for them

consent_granted and consent_requested are automation triggers. They always land in the contact's event timeline, but they cause nothing (no double opt-in mail in particular) unless the app has an automation bound to that event name. Conflux cannot verify this over the API; it has to be configured per app.

consent_granted is also the event for a withdrawal

User.com documents no revocation event, and its own example payload carries a false flag inside a consent_granted event. An automation must branch on email_opt_in in data, not on the event name alone.

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. Until then the contact sends no marketing.

An address change does not ask again

A consent is delivered only when its own value changed. An address change moves the customer, not the consent, so it fires no second consent_requested, and neither does the confirmation Positive User reports back. The branch's memory table (user_platform_consent_deliveries) is not needed.

Products

The writer's third domain is products, a root domain like customers. The ledger holds the numeric id the app assigns, and every later write addresses the product by it. The decisions behind the shape are in ADR-0047.

The custom_id

POST products-by-id/ requires a custom_id and the app keeps it unique in the workspace. UserPlatformProductMapper::customId() takes the product's SKU, or its EAN where there is no SKU, trimmed and otherwise as the shop holds it. The EAN is ProductData::$ean, or the shop's custom attribute named ean where the field is empty. A product with neither is refused with an UndeliverableRecordException: the ledger row is undeliverable, last_error says what to fill in, and the rule's delivery records in the admin panel show it.

Every update sends the current custom_id as well, so a SKU changed at the source renames the remote product in place.

Only products Conflux created

The app answers 400 {"custom_id": ["product with this Custom ID already exists."]} when another product holds the key, on a create and on a rename. The writer turns that into an UndeliverableRecordException that names the key. Nothing is merged into the other product. Two source products with the same SKU end up as one delivered and one undeliverable.

An update the app answers with 404

A product deleted in the workspace is not created again. An update answered with the app's own 404 (a JSON body carrying detail, or no body) is a RemoteRecordGoneException, exactly as for a contact: the row becomes remote_gone and delivery of that product stops (ADR-0015). An HTML error page stays an ordinary, retryable failure.

The payload

Part Written
Model fields custom_id, name, description, product_url, image_url, brand_name, target_group, created_at, updated_at, each when the record carries a value
conflux_source Always: the source connection's name (sourceConnectionName), else its host, else the platform
Fixed attributes sku, ean, price, original_price, currency, status, only where the workspace's products already have an attribute of that name. price and original_price are decimals in major units as strings ("899.00"), converted from minor units
The shop's custom attributes Each under its own key, only where the workspace's products already have it. A null value is left out
Never written Categories and tags

The app turns every key a product is written with into an attribute of every product in the workspace, so writing what the shop holds would rewrite the workspace's product schema. A product attribute is therefore written only where the workspace already has it.

UserPlatformProductSchema reads the names from GET products/: the keys of the first result, minus the model's own keys. The answer is cached for 300 seconds per connection and subdomain, so an attribute the workspace owner just created is seen after at most five minutes. A workspace with no product yet answers an empty list. A schema read that fails fails the write as a retryable OutboundWriteException.

A shop custom attribute whose key collides with a reserved key (the model fields, the fixed attributes, conflux_source and the app's own keys) is refused as undeliverable, naming the key.

Deletion

DELETE products/{id}/ is a hard delete, because the app has no inactive state for a product either. The seeded Positive User products direction is push_out, with delivery_defaults of overwrite and delete. A mass deletion is held by the volume breaker like any other withdrawal (ADR-0035).

What a deletion at the source can do here

delete is declared, deactivate is not, so a rule has two outcomes to choose between for this target: remove the contact, or leave it untouched. There is no third state in between — the workspace has no inactive state of its own, and Conflux does not invent one in somebody else's schema.

The removal is the app's hard delete, the only one it offers: the contact and everything the workspace recorded about it are gone, and nothing Conflux holds can put either back. Declaring the verb says the target can delete, not that deleting is the right answer to a source record going away — that is the rule's delete_policy (ADR-0016), and a rule that leaves it at ignore never reaches the verb.

deactivate stays out because the only way to build the state it needs is a custom attribute the workspace would have to create before the first delivery, and reusing unsubscribed for it is ruled out by ADR-0016: a record leaving a shop says nothing about what that person agreed to, so an ordinary removal must not read as an objection.

A reference naming a domain other than customers or products is refused with an UndeliverableRecordException before the request leaves. A delete is handed a bare reference with no projection beside it, so that check is the only thing between a wrong domain and a DELETE on whichever contact happens to hold that number.

Open against the epic

Push-Out Epic B lists deactivate as needing its own custom attribute, and ADR-0016 assumes a target with a lifecycle state can offer the deactivate policy. This writer declares neither, for the reasons above. The contradiction is recorded rather than resolved here.

Response handling

Outcome Result
PUT (update) returns 404 in the app's own shape — a body carrying detail ({"detail": "Not found."}), or no body at all RemoteRecordGoneException — the numeric id User.com assigned is never reissued, so this is unambiguous. A contact or product deleted in the workspace is not created again
POST or PUT of a product returns 400 with custom_id naming a key that already exists UndeliverableRecordException naming the key, see Products
PUT (update) returns 404 with any other body Ordinary OutboundWriteException — a proxy's or a framework's error page means the request never reached the contact resource, and reading that as gone would stop delivering every record of the run for good. JSON is not the test: a WAF answers a blocked path with {"message": "Not Found"} as readily as with HTML, so only the shape separates the two
DELETE returns 404 in the app's own shape RemoteRecordGoneException — somebody removed the contact in the workspace first, which is where a successful delete leaves the record too: the ledger marks it remote_gone and stops delivering it
DELETE returns 404 with any other body Ordinary OutboundWriteException, for the same reason as the update row above — a request that never reached the contact resource says nothing about whether the contact is still there
POST (create) returns 404 Ordinary OutboundWriteException — there is no contact yet, so a 404 here means a wrong base URL or a workspace that no longer exists, not a deleted record
POST (create) succeeds but the response carries no usable id UnaddressableRemoteRecordException — the contact exists in the workspace and nothing points at it; retrying would create a second unreachable contact rather than repair the first. It carries ProvesIntegrationReachable: the workspace accepted the write, so the circuit stays closed rather than opening on a response shape the writer could not read
Any request returns 429 TargetRateLimitedException, carrying retry-after parsed as whole seconds. A missing or non-numeric header gives retryAfterSeconds: null
Any other non-2xx response OutboundWriteException, carrying the parsed error message (see below). A refusal the workspace made is an ordinary write failure even when it names the field it disliked — where the refusal was decided is what separates it from UndeliverableRecordException

$remoteId is what separates the two readings of a 404: passed for update and delete, null for create. The body separates them a second time — see the two update rows above.

Connections the writer refuses to deliver to

requireDeliverableConnection() (the AbstractOutboundWriter hook) asks UserPlatformClient::deliveryRequest() before the write enters the circuit breaker and the error mapping. Both of its refusals therefore keep their own type and travel to the caller untouched:

Condition Exception
The connection's status no longer entitles it to deliveries (uninstalled, disconnected, inactive) IntegrationRequestException
Credentials the workspace cannot be addressed with — a missing or malformed subdomain, a token that is not 64 characters, a base_url naming a different host UnusableCredentialsException

Neither is an OutboundWriteException and neither counts a circuit-breaker failure, which is the point of checking them there. Raised inside the breaker's callback — which is what happens when a writer only asks for its request inside performCreate()/performUpdate() — they would be rewrapped as retryable write failures, so a rotated token would put every record of a run on a retry schedule that cannot succeed and open the circuit over requests that never left.

Error message parsing

errorMessage() tries three response shapes, in order, then falls back to the raw body:

  1. A single string under detail, error or message.
  2. A field-to-messages map (what validation failures answer with) — folded into field: message pairs joined by ;. This is what makes a rejected custom attribute legible: User.com names the offending key.
  3. A bare list of messages — folded the same way, without a field prefix.

Where none of the three matches, the message is the raw response body, trimmed and truncated to UserPlatformOutboundWriter::RAW_MESSAGE_LIMIT (1000) characters, or no message where the body is empty. The cap is what keeps a full HTML error page out of last_error, a text column an operator reads the first line of.

If the response carries a retry-after header (User.com sends it lowercase; header() compares case-insensitively), its value is appended to the message as (retry after {n}s) — so a rate limit carries its wait into the ledger's last_error, which is where an operator looks first. On a 429 the same value also travels as TargetRateLimitedException::$retryAfterSeconds, so the delivery layer reads it without parsing the message a second time.

Records the writer refuses to deliver

Each raises App\Exceptions\UndeliverableRecordException, not OutboundWriteException: the source row has to change first, so a retry asks the same question of the same row forever. The exception carries ReportsARecordFault, so the circuit breaker leaves its state untouched — a batch holding a few of these must not take a healthy connection down (ADR-0032).

Condition Note
Neither email nor a phone number that normalises to E.164 The contact would have no addressee in either channel. A phone-only customer is delivered normally; SMS is a channel like any other
A custom attribute whose key collides with a reserved payload field (email, custom_id, unsubscribed, conflux_source, etc. — see UserPlatformContactMapper::RESERVED_KEYS) The message names the offending key. A collision is refused rather than silently dropped, because a dropped attribute looks exactly like one the shop never sent
A product with neither a SKU nor an EAN, a shop attribute colliding with a product's reserved key, or a custom_id another product in the workspace already holds See Products
A projection whose domain is none of customers, products and, on an update, customer_consents Mis-routed before it reached the writer. A create for a consent is refused the same way

Workspace prerequisites

The custom attribute conflux_source has to be declared in the target workspace before the first delivery, because it travels on every payload. Connecting declares it — see Declaring conflux_source — so a workspace that went through the handshake needs no preparation by hand.

It matters that this is handled rather than documented as a step, because the app gives no sign when it is missing: a contact carrying an attribute the workspace has not declared is accepted with 201 and the value is dropped without a word. A workspace nobody prepared would hold contacts with no source marker and nothing would fail to reveal it. A workspace this writer reaches without ever having been connected — a live test pointed at a fresh one, say — is the one case left where the attribute has to be declared by hand.

Test coverage

Tier File Covers
Unit tests/Unit/Integrations/UserPlatform/UserPlatformOutboundWriterTest.php Declared capabilities, create/update against Http::fake(), the full field mapping, records refused, error message parsing across all three response shapes
Unit tests/Unit/Services/PhoneNumbersTest.php PhoneNumbers::toE164() in isolation — national and international numbers, absent input, unresolvable input, non-ISO region values
Unit tests/Unit/Integrations/UserPlatform/UserPlatformConsentMapperTest.php callsFor() per consent state
Unit tests/Unit/Integrations/UserPlatform/UserPlatformProductMapperTest.php The product payload, custom_id from SKU or EAN, attributes written only where the workspace has them, reserved keys refused
Unit tests/Unit/Integrations/UserPlatform/UserPlatformOutboundWriterProductTest.php Product create/update/delete against Http::fake(): the numeric id kept, the rename, the taken key on create and rename, a 404 the workspace answered read as gone, the attribute read and its cache
Integration tests/Integration/Integrations/UserPlatform/UserPlatformOutboundWriterConsentTest.php The consent flow against Http::fake(): requests and their order, accepts(), the gone signal on the first request only, failures on each step, and the refused create
Feature (e2e) tests/Feature/Outbound/ConsentFlowE2eTest.php The three consent flows through the real chain, with only outbound HTTP faked
Feature (e2e) tests/Feature/Outbound/UserPlatformProductDeliveryE2eTest.php Product delivery through the real chain with only outbound HTTP faked: create under the SKU, rename on update, a product with neither SKU nor EAN, a taken key, and a mass deletion held by the volume breaker and deleted once released
Feature (e2e) tests/Feature/Outbound/UserPlatformCustomerDeliveryE2eTest.php The real chain — persisted Customer, the customers projector, OutboundWriterRegistry, this writer — with only the outbound HTTP call faked. Asserts both the returned reference and the request sent
Live tests/Live/UserPlatformLiveConsentWriteTest.php That email_opt_in is kept, that consent_granted answers 2xx on the numeric id, that the mass update moves unsubscribed both ways, and that a deleted contact reads as gone. Never fires consent_requested; see the consent tiers
Live tests/Live/UserPlatformLiveProductWriteTest.php The product assumptions of ADR-0047 against a real workspace: create returns a numeric id, a PUT with a changed custom_id renames the product, a taken key is refused on create and rename and the other product is left alone, an update of a product deleted in the workspace is reported as gone and nothing is created, an attribute the workspace's products lack is not created, and a delete removes the product
Live tests/Live/UserPlatformLiveCustomerWriteTest.php The assumptions that fail silently against a real workspace: that create returns a usable numeric id, that phone_number/gender are taken under those names, that a declared custom attribute is kept while an undeclared one is dropped in silence, that a 404 on update or delete means gone rather than recreated, and that a delete really removes the contact

Running the live tests

The live tier needs a real User.com workspace with conflux_source declared in it — connecting declares it, so this only has to be done by hand for a workspace that has never been connected. Configure it with:

docker compose exec \
  -e USER_PLATFORM_LIVE_SUBDOMAIN=<workspace subdomain, without the apex> \
  -e USER_PLATFORM_LIVE_API_TOKEN=<64-char token> \
  php composer test:live -- --filter UserPlatformLiveCustomerWrite
Env var Purpose
USER_PLATFORM_LIVE_SUBDOMAIN The workspace under test, without the apex
USER_PLATFORM_LIVE_API_TOKEN That workspace's 64-character API token

The same pair UserPlatformLiveVerificationTest takes — it is the same credential in two parts. USER_PLATFORM_API_HOST has to be set in the container's environment as well, since the apex that completes the host has no default.

Both unset skips the whole file. The run creates real contacts and deletes each one again through the app's hard delete in afterEach(); every contact it creates carries conflux_source = live-test, which is what makes one left behind by a crashed run findable. See Run the Live Tests for the shared conventions this tier follows.

See also

  • Outbound Writer Contract — the capability interfaces, traits, exceptions and registry this writer is built against
  • ADR-0031 — why the writer creates and updates by numeric id rather than upserting by custom_id
  • ADR-0047 — how a product is keyed, addressed and written
  • ADR-0045 — the consent flow, and why it lives in this writer
  • ADR-0015 — why Conflux creates a record in the target rather than resolving one
  • ADR-0021 — why deactivate and bulk ingestion are simply absent from the type rather than refused at runtime
  • ADR-0032 — why a record this writer refuses is neither retried nor counted against the connection's health
  • Implement an Outbound Writer — the general how-to, now with this writer as the worked example