User Platform Integration¶
Reference for App\Integrations\UserPlatform\UserPlatformIntegration and its collaborator
App\Integrations\UserPlatform\UserPlatformClient. Covers the credential shape, the connectivity
verification contract, the fail-closed delivery guard, the consent read paths (on-demand pull and
automation webhook), the Positive User consent mirror the webhook writes, how the webhook is set up
in a workspace, and the test coverage for each.
The external product this integration addresses is Positive User (user.com). A user.com workspace is the target of a Push-Out flow: Conflux creates contacts in the workspace (ADR-0031), and the only thing travelling back is the consent those contacts carry. This page covers the capabilities both directions depend on. The contact and consent writes themselves are the Outbound Writer reference. How credentials reach Conflux in the first place is out of scope; see Out of Scope below.
Class¶
Namespace: App\Integrations\UserPlatform\UserPlatformIntegration
Extends: App\Services\AbstractIntegration
Implements: CanHandleWebhooks, CanPullConsent, CanVerifyConnections, HasConsentChannels (use HandlesWebhooks, PullsConsent, VerifiesConnections)
Registered in App\Providers\AppServiceProvider::boot():
Constructed by the container with UserPlatformClient, ConsentRecorder and
UserPlatformConsentMapper, none of them defaulted, so a bound or decorated collaborator reaches it.
Consent reaches only contacts Conflux created
Every consent path on this page identifies a contact by the numeric id the workspace assigned
when UserPlatformOutboundWriter created it, and that id is looked up in the delivery ledger
before anything else happens. A contact the ledger does not hold is one of the workspace's own,
and is never read. See
ADR-0044.
Credentials¶
database/seeders/SystemSeeder.php declares two auth_fields for the user-platform system:
| Field | secret |
Description |
|---|---|---|
subdomain |
false |
The workspace identifier. Interpolated into the host every request is sent to: https://<subdomain>.<apex>, where the apex is conflux.user_platform.api_host. |
api_token |
true |
A user.com API token, exactly 64 characters. Sent as Authorization: Token <api_token>. |
credentials['webhook_secret'] carries the HMAC secret the consent webhook is verified with, by
ConnectionSignatureValidator. It is not an auth_field: nobody supplies it. Conflux mints it on
the connection's first successful verification and never replaces it, see
Setting up the consent webhook.
Unlike every other registered integration, SystemConnection::base_url is not read as the host.
See Why the subdomain, not base_url below.
There is no data migration from this system's previous single-credential shape (api_key). A
connection still carrying the old field has to be given both subdomain and api_token by hand —
a subdomain cannot be derived from a bare token. See
ADR-0028.
UserPlatformClient¶
Namespace: App\Integrations\UserPlatform\UserPlatformClient
The single place every authenticated request into a workspace is built. Public API:
public function validateCredentials(SystemConnection $connection): void;
public function subdomain(SystemConnection $connection): string;
public function endpoint(SystemConnection $connection, string $path): string;
public function request(SystemConnection $connection): PendingRequest;
public function deliveryRequest(SystemConnection $connection): PendingRequest;
endpoint()¶
Composes an absolute URL under the workspace's public API from the subdomain credential and
the configured apex conflux.user_platform.api_host (USER_PLATFORM_API_HOST). Configuration
rather than a constant, because it was measured: the staging estate serves its workspaces from
staginguser.com while production serves them from user.com, so the same subdomain names a
different workspace on each.
The setting has no default, and an unset or empty value is an error rather than a fallback. A
default could only name one estate, and an environment that never set it would then address the
production apex with whatever subdomain it holds — writing into a live customer's workspace
instead of failing. Empty counts as unset for the same reason, and is not hypothetical: CI builds
its environment with cp .env.example .env, so the variable arrives declared and empty, and an
empty apex would compose the host <subdomain>. and fail as a URL mismatch rather than as a
statement about configuration. UserPlatformClient::host() refuses both — Config::string()
rejects an unset value and names the key, and an explicit check rejects an empty one — so the
request never leaves. Every workspace
on a given estate is served from that same
apex domain:
SystemConnection::base_url plays no part in this — see
Why the subdomain, not base_url below.
request()¶
Returns a PendingRequest with two headers set:
| Header | Value | Why |
|---|---|---|
Authorization |
Token <api_token> |
The workspace-bound API token |
Accept |
*/*; version=2 |
Selects API version 2. Deliberately not acceptJson(), which sets Accept: application/json and would displace the version selection. user.com documents a missing or wrong value as producing unexpected behaviour rather than a clean error — which is why the header lives in UserPlatformClient::request() alone and is asserted by a test, rather than being left to each call site to remember |
No TLS accommodation is made, unlike the shop integrations: every workspace here is a public host on a real certificate, so there is no local self-signed store to allow for.
validateCredentials()¶
Runs before any request goes out and throws UnusableCredentialsException naming, in one sentence,
the first thing wrong with a connection's credentials. It returns nothing on success, so a caller
cannot proceed with credentials it forgot to check — which on the delivery path would mean a write
leaving for a host nobody chose:
| Condition | Message |
|---|---|
| Missing subdomain | The Positive User workspace subdomain is missing. |
| Subdomain is not a single DNS label | [<subdomain>] is not a usable Positive User workspace subdomain. |
| Missing token | The Positive User API token is missing. |
| Token is not exactly 64 characters | The Positive User API token must be %d characters long, and this one is %d. |
base_url names a host other than <subdomain>.<apex> |
The connection names [<base_url host>] as its base URL, and subdomain [<subdomain>] names [<subdomain>.<apex>]. |
The subdomain check exists because the subdomain is interpolated straight into a host: a value
carrying a dot or a slash could move the request to a host of its author's choosing while still
looking like a configuration typo. The base_url check exists because base_url is otherwise
unused here — it costs nothing to check, and it catches a stray or copy-pasted value left over from
another driver's connection form.
deliveryRequest()¶
request() plus a fail-closed guard, used by every request UserPlatformOutboundWriter sends:
/** @throws IntegrationRequestException */
public function deliveryRequest(SystemConnection $connection): PendingRequest;
Throws IntegrationRequestException when the connection is not isHealthy() (i.e. not
Active), and lets validateCredentials()'s
UnusableCredentialsException through when the credentials are unusable. Separate
from request() because the two methods have opposite preconditions: verification legitimately runs
against a pending, often unsaved transient connection — that is the whole point of a transient
connection — so request() cannot require an active one. A delivery is the reverse: nothing may be
written once a connection has stopped being active, whatever its credentials still say.
Connectivity Verification¶
UserPlatformIntegration implements performVerification(SystemConnection $connection): VerificationResult,
called by the sealed VerifiesConnections::verify(). Any uncaught Throwable from
performVerification() is caught by verify(), logged, and converted into a failed
VerificationResult carrying the fixed message 'The external system could not be reached.' — see
ADR-0028.
What it does¶
- Calls
UserPlatformClient::validateCredentials()and catchesUnusableCredentialsException, returningVerificationResult::failure($e->getMessage())without making a request. This is the one exception type whose message is forwarded rather than logged and replaced — seeVerificationResultshape below. GETs/api/public/attributes/— the workspace's own custom-attribute schema — authenticated viaUserPlatformClient::request().- Returns a
VerificationResultbased on the response status (see Outcomes below). - On success, declares
conflux_sourcewhere that list does not already name it (see Declaringconflux_source).
Endpoint¶
Why /api/public/attributes/, not a contact list¶
The call is deliberately not a read of contacts. The design for this integration is that a workspace's own people are never read by Conflux — only records Conflux itself created are ever addressed — and a verification that paged through somebody's contacts would break that rule on the very first request, before a single record has been delivered. Listing the attribute schema answers the same underlying question a contact read would (do these credentials work, and against which workspace), and it also answers a question the first delivery asks anyway: a custom attribute the workspace has not declared is dropped on write without a word.
Which workspace was reached is not inferred from the response body — it is structural. The subdomain
is the host, so a token belonging to another workspace is rejected there, and a subdomain nobody owns
does not resolve at all. That is why VerificationResult::success() carries the subdomain: it is
the connection's record of the workspace it proved it could reach.
Declaring conflux_source¶
Verification reads the attribute list for the token check, and the same answer says whether the
workspace declares conflux_source — the marker every contact Conflux creates carries
(writer reference). Where it is missing, verification declares
it:
POST /api/public/attributes/ {"name": "conflux_source", "type": "text", "content_type": "clientuser"}
| Fact | Value |
|---|---|
| When | Only where the fetched list does not name the attribute — no extra request otherwise |
content_type |
clientuser, which is what a contact is to this endpoint. It refuses anything outside clientuser, activity, company, deal, product, ticket — and answers the same attribute back from the GET as user, so the lookup matches on name |
| Already there | 409 {"errors": "Field with such name already exists"}, taken as success. That is also why the list is read one page deep although it is paginated: an attribute on a later page is answered by the app rather than by a page walk |
| Refused | Logged as a warning naming the workspace; the verification still succeeds |
The reason it is declared at all rather than asked for: a contact carrying an attribute the
workspace has not declared is accepted with 201 and the value is dropped without a word. Measured
against a live workspace — a declared attribute comes back on the contact, an undeclared one is
nowhere, and no response says so. A workspace nobody had prepared would therefore hold contacts with
no source marker at all, with nothing failing anywhere to reveal it.
A failure to declare it does not fail the connection, because what is lost is a label rather than a record: deliveries still create and update contacts correctly. Refusing the whole connection over a marker would be the heavier error.
Outcomes¶
| Condition | VerificationResult returned |
|---|---|
validateCredentials() throws |
VerificationResult::failure($e->getMessage()) — no request made |
200 |
VerificationResult::success($subdomain) |
401 or 403 |
VerificationResult::failure('The Positive User workspace [<subdomain>] rejected the API token.') |
410, 404, or Illuminate\Http\Client\ConnectionException |
VerificationResult::failure('No Positive User workspace answered at subdomain [<subdomain>].') |
| Any other failure status | VerificationResult::failure('Positive User answered the check for workspace [<subdomain>] with HTTP <status>.') |
410 is what a live estate actually answers for a subdomain nobody owns, measured rather than
assumed: the apex carries wildcard DNS, so an unowned subdomain resolves, completes TLS, and is
refused by the application itself. Without it in this row, the single mistake this integration exists
to catch would read as answered the check with HTTP 410 — a status code instead of the subdomain to
fix. 410, 404 and a ConnectionException share one message: all mean the subdomain names nothing reachable,
whether because DNS does not resolve it or because the host resolves but the public API is not
there — and both call for the same correction.
VerificationResult shape¶
Namespace: App\Data\VerificationResult
| Property | Type | Description |
|---|---|---|
successful |
bool |
true on success, false on any failure |
externalUserId |
?string |
The connection's subdomain on success; null on failure |
message |
?string |
One of the sentences above on a named failure; the fixed sentence above when verify() caught an exception instead |
No current caller reads message — every existing caller of verify() (RegisterSystemConnectionController,
ConnectSystemConnectionController, the Filament ConnectSystemConnectionAction) answers a failed
verification with the same fixed string for every integration, and a Positive User connection does
not reach any of them today — see Out of Scope below. The field is still curated
deliberately: see ADR-0028
for why. See Integration Contract reference — Verification DTO.
Why the subdomain, not base_url¶
Every other registered integration reads SystemConnection::base_url as the host it talks to.
Positive User does not: the host is built from the subdomain credential alone, and base_url is
read only to be compared against it, never to route a request. This is a deliberate departure from
the pattern the shop integrations set, not an oversight — see
ADR-0028 for the
full reasoning: a token that is valid but paired with the wrong subdomain does not fail to
authenticate, it authenticates against a different workspace and writes one merchant's data into
another merchant's account, and two independent sources for one host is what makes that possible.
Fail-Closed Delivery Guard¶
UserPlatformClient::deliveryRequest() is the request every write into the workspace is built from,
the contact write and each step of the consent flow alike. It refuses a connection that is not isHealthy() and a connection whose credentials
validateCredentials() rejects — throwing IntegrationRequestException and
UnusableCredentialsException respectively, rather than returning a request that would quietly go
out. See deliveryRequest() above.
Consent Channels¶
supportedConsentChannels() returns [ConsentChannel::Email, ConsentChannel::Sms], the same list
UserPlatformOutboundWriter declares. Both read UserPlatformConsentMapper::CHANNELS. The platform
has no notion of a general, channel-less consent, so General is refused. See
HasConsentChannels.
| Channel | Contact fields | Outbound | Pull | Webhook |
|---|---|---|---|---|
email |
core email_opt_in, with the suppression flag unsubscribed |
yes | yes | extra.email_opt_in |
sms |
the suppression flag unsubscribed_from_sms only; there is no SMS opt-in field |
yes | no: no API read returns the flag | extra.unsubscribed_from_sms, withdrawals only |
SMS is write-only through the API
unsubscribed_from_sms is a standard boolean attribute that PUT users/{id}/ accepts and
validates (a non-boolean answers 400). It was found through OPTIONS users/{id}/ on 2026-10-07
and confirmed in the User.com UI. GET users/{id}/, the list and the search never return it, so
the pull stays email-only and SMS comes back over the webhook alone.
No shop produces SMS consent today
WooCommerce and PrestaShop emit email consent only, and their writers declare email only. The
outbound SMS path therefore waits for a source that produces SMS consent. Today SMS flows only
inbound: an SMS withdrawal reported by the webhook lands in the Positive User mirror, and a rule
carrying it on to a shop is refused there by the shop writer's accepts().
The SMS mirror is one-way: the last withdrawal seen, not the live state
The Positive User connection's SMS row is only ever written as a withdrawal, and nothing clears
it. A later subscription, set in the app or written by Conflux, is never reported back. So
fullyReportedConsentChannels() (HasFullyReportedConsentChannels) returns [Email].
Delivery's echo and conflict check (DependentEchoes) ignores a target mirror row on a channel
its integration does not report in full. A shop's SMS withdrawal is therefore always delivered,
even when the stale row already reads "withdrawn". The admin consent view marks the row
Last reported, not live, and it never makes a view Differ. See
ADR-0045.
This was [Email, Sms] until a live app was measured, then [Email] until 2026-10-07
User.com's documentation describes marketing consent as custom attributes named email_consent
and sms_consent. A live app defines no such attributes: GET /api/public/attributes/ answered
count: 0, and no contact carried an attributes key at all. It exposes a core email_opt_in
field that carries exactly the signal. The mapping moved to that field, and SMS lost its home in
the process, since the contact has no sms_opt_in. It came back once the suppression flag
unsubscribed_from_sms was found
(ADR-0045).
The Positive User Consent Mirror¶
The webhook writes into the Positive User connection's own mirror, through
ConsentRecorder::write(), like any source does
(ADR-0041). The pull
returns the contact's state under the same mapping and boundary and writes nothing; nothing in
production calls it yet. Nothing on either path writes to a shop's records.
customer_consents column |
Value |
|---|---|
system_connection_id |
The Positive User connection |
parent_source_id |
The contact's numeric id, the ledger's remote_id |
source_id |
<contact id>:email, or <contact id>:sms from the webhook only (ConsentData::sourceIdFor()) |
source_system |
user-platform |
customer_id |
null: this connection mirrors no customers |
The row reaches the shop through a Positive User → shop consent rule. Its anchor is the reverse
customers rule (shop → Positive User): the ledger row whose remote_id is the contact id has the shop
customer's id as its source_id
(ADR-0042). The
recorder's rules hold here as everywhere: an unchanged value leaves the change signal alone, and
null never overwrites a known value, except that a withdrawal clears the confirmation.
The direction matrix lists customer_consents for Positive User as push_in and push_out.
The ledger boundary¶
Both paths first ask the delivery ledger whether Conflux created the contact on this connection:
OutboundDelivery::query()
->createdAs($connection, new RemoteReference(SystemCapability::Customers, $userId))
->exists();
scopeCreatedAs() matches a customers row with that remote_id, whose rule targets this
connection, and which scopeKnownToTarget() keeps: a reference is stored and the row is not
withdrawn. A contact created in another workspace, one whose create never succeeded, and one
withdrawn with delete all fail the check.
Path 1: On-Demand Pull¶
performPullConsent(SystemConnection $connection, string $customerSourceId): ?ConsentData, called
through the sealed CanPullConsent::pullConsent().
{id} is User.com's own numeric id, the ledger's remote_id for a contact Conflux created on
this connection, and is what $customerSourceId carries.
The ledger is checked first. An id that fails the ledger boundary is
refused with ConsentTargetNotFoundException before a request goes out.
Why not the custom_id-keyed endpoints
User.com also addresses a contact by custom_id through users-by-id/{custom_id}/. Conflux uses
neither that endpoint nor any of its siblings: they carry side effects, and update_or_create is
the extreme case, since it creates the contact it cannot find. Addressing one identity everywhere
keeps the read and the write on the same contact, and keeps a read free of consequences.
Response handling¶
| Status | Result |
|---|---|
| not in the ledger | ConsentTargetNotFoundException, no request made |
404 |
ConsentTargetNotFoundException: Conflux created the contact, the workspace no longer holds it |
| other non-2xx | IntegrationRequestException carrying the status code |
2xx |
mapped by UserPlatformConsentMapper::fromContact() to ConsentData, or null for a contact never asked |
Field mapping¶
| Contact | ConsentData |
|---|---|
email_opt_in: true |
subscribed: true, confirmed: true |
email_opt_in: false |
subscribed: false, confirmed: null |
email_opt_in: null, unsubscribed: true |
subscribed: false, confirmed: null |
email_opt_in: null, unsubscribed false or missing |
no consent ("never asked") |
The other fields: channel is always ConsentChannel::Email, source is always
ConsentSource::Integration, and consentUpdatedAt is the contact's updated_at (see below).
subscribedAt, confirmedAt, unsubscribedAt and ipAddress stay null, because User.com exposes
no equivalent.
Why email_opt_in wins over unsubscribed
email_opt_in has three states, and the third is the one that matters: true is a consent,
false a withdrawal, and null a contact who was never asked. Conflux records nothing for that
third state, because "never asked" is not a consent state. Against the live staging app, 52 of 54
contacts sat in exactly that state.
unsubscribed is the platform's own email-suppression flag. It is read only as a
withdrawal: an explicit true with no opt-in either way is recorded as one. The inverse is
deliberately not symmetric. unsubscribed: false is the default every contact carries from
creation, so reading it as a subscription would record consent for the entire address book.
An explicit opt-in is recorded as confirmed, on the reasoning that the app sets the field at the end of its own double opt-in rather than at the start. Nothing in the API tells the two apart, so no test can settle it. If an app is configured to set the field before confirmation, Conflux would propagate an unconfirmed consent as confirmed.
Values come back typed as stored (true, "true" and 1 all occur), so they are coerced rather
than checked with a strict is_bool().
Timestamps¶
User.com exposes no consent-scoped timestamp on the contact, only the record's own updated_at. It
stands in for consentUpdatedAt, the same proxy the shop mappings use. It is parsed through
SourceDateTimes against UserPlatformIntegration::DATETIME_FORMAT (Y-m-d\TH:i:s.uP), after
canonicalise() has rewritten the notation: a trailing Z becomes +00:00, and a missing fractional
part becomes .000000. User.com sends the same instant as …T09:15:00.000000Z from the People API
and as …T12:50:52+00:00 or …T12:50:52.455845+00:00 from an automation webhook.
A contact without a readable updated_at yields no consent rather than one stamped with now().
consentUpdatedAt is required, and a pull can simply be repeated.
Path 2: Automation Webhook¶
processWebhook(SystemConnection $connection, array $payload): void, called through the sealed
CanHandleWebhooks::handleWebhook() by ProcessWebhookJob. Positive User
sends it from a "Send API Call" automation module. The receiver, signature validation and storage
are the platform-agnostic ones described in Webhook Receiver.
Payload¶
{
"path": 12345,
"timestamp": "2026-08-30T12:50:52.455845+00:00",
"extra": {
"user_id": "8123",
"email_opt_in": false,
"unsubscribed_from_sms": true,
"change_reason": "unsubscribe_link"
}
}
path is the automation's id, and timestamp is set by User.com. extra is configured per
automation, so its shape is a convention rather than a guarantee, and everything read from it is
treated as optional. unsubscribed_from_sms in particular is optional: an automation that does not
send it reports email only.
What it does¶
- Reads
extra. A payload without anextraobject is discarded with a warning. - Identifies the contact from
extra.user_id, sent as a string or a JSON number. A payload without one is discarded with a warning. An email address is not used to identify a contact. - Checks the ledger boundary. A contact Conflux did not create on this connection is skipped with a warning, and nothing is written or sent.
- Maps
extra.email_opt_inandextra.unsubscribed_from_smsthroughUserPlatformConsentMapper::fromWebhook(), into up to two consents: email first, then SMS. - Writes the result into the Positive User mirror through
ConsentRecorder. Webhooks arrive in no guaranteed order, and the recorder's stale-write guard skips a channel whosetimestamplies before theconsent_updated_atits mirror row already holds, with a warning, so a retried older webhook cannot undo a newer change. An equal timestamp is written; the recorder moves nothing for a value it already holds. See Internal Consent Recorder.
Field mapping¶
extra.email_opt_in |
ConsentData |
|---|---|
true |
subscribed: true, confirmed: true |
false |
subscribed: false, confirmed: null |
| missing, empty, blank or unreadable | nothing written |
extra.unsubscribed_from_sms |
ConsentData (channel sms) |
|---|---|
true |
subscribed: false, confirmed: null |
false |
nothing written |
| missing, empty, blank or unreadable | nothing written |
unsubscribed_from_sms: false is read as nothing for the reason unsubscribed: false is on the
pull: it is the default every contact carries, so reading it as a subscription would record SMS
consent for every contact that never gave any. With no SMS opt-in field on the contact, a set flag is
the only SMS state a webhook reports.
consentUpdatedAt is the payload's timestamp, parsed as on the pull. change_reason is not
persisted, since CustomerConsent has no field for it.
A missing flag is not a withdrawal
A flag absent from extra, empty, or unreadable writes nothing. It is never defaulted to
false: "said nothing" must not become "withdrew consent". An empty string matters here because
filter_var('', FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) answers false, and a template
placeholder that rendered empty would otherwise arrive as a withdrawal.
Why the webhook falls back to the time of receipt where the pull refuses to
A pull can be repeated, a webhook cannot. Dropping the event loses the change for good, and a
withdrawal must always propagate. So a payload with a missing or unreadable timestamp is
stamped with the time of receipt, and a warning is logged. The two paths differ on purpose:
refusing is safer when the data can be read again, accepting is safer when it cannot.
Setting up the consent webhook¶
One automation per workspace, built in User.com by whoever operates the workspace:
- Trigger: the change of the contact's
email_opt_inattribute, both directions. Positive User sets the field at the end of its own double opt-in and clears it on a withdrawal, so this is the moment the consent changes. Where SMS is reported too, also the change of the contact's SMS unsubscribe (unsubscribed_from_sms). - Action: a "Send API Call" module,
POSTtohttps://<conflux host>/api/webhooks/{connection id}, where{connection id}is the Positive User connection's UUID. -
Body, as JSON with
Content-Type: application/json:{ "path": <automation id>, "timestamp": "<the event's timestamp>", "extra": { "user_id": "<the contact's numeric id>", "email_opt_in": <the contact's email_opt_in>, "unsubscribed_from_sms": <the contact's SMS unsubscribe> } }unsubscribed_from_smsis optional. Send it to report SMS withdrawals; leave it out and the webhook speaks for email only. To catch an SMS withdrawal as it happens, trigger the automation on a change of the contact's SMS unsubscribe as well.user_idis the contact's numeric User.com id, notuser_custom_idor the email, see the warning below.timestampis accepted with or without a fractional second, with+00:00orZ. 4. HeaderSignature: the hex-encoded HMAC-SHA256 of the exact body the module sends, keyed with the connection's webhook secret. The bytes signed and the bytes sent have to be the same; a body re-serialised after signing fails the check with403. See Webhook Receiver.
Where the secret comes from. UserPlatformIntegration::performVerification() mints it, 32 random
bytes hex encoded, on a successful verification of a connection that holds none, and the caller
saves it into the encrypted credentials. It is never replaced: the automation carries a copy, and a
reconnect onto a disconnected sibling keeps the sibling's secret. An operator reads it on the connection's View page in the admin panel: Webhook URL (copyable)
and Webhook secret (copyable, super admins only, and left out of the page for everyone else) sit in
the facts card. A connection verified before the secret existed has none until it is verified again, and
its webhooks are refused meanwhile; a super admin can give it one at once with the Generate webhook
secret header action, which uses the same logic as verification and never replaces a secret the
connection already holds. Nothing shows or rotates a secret otherwise.
The automation has to be configured to send user_id
An automation that sends only user_custom_id or email, which are User.com's own example
fields, identifies no contact Conflux may act on: both would match the workspace's own contacts
as readily as Conflux's. Such a payload is skipped with a warning. Conflux cannot check this
configuration over the API. See
ADR-0044.
Outbound Consent Write¶
Consent goes into the workspace as a customer_consents update through UserPlatformOutboundWriter,
the one writer registered for this driver. The flow, the request order and the failure semantics are
in the Outbound Writer reference: Consent and in
ADR-0045. An
unconfirmed subscription is never written as a field. It is requested over consent_requested, and
the confirmation comes back over Path 2.
Outbound Product Write¶
Products go into the workspace through the same writer, created under their SKU or EAN as custom_id
and addressed by the numeric id the create returned. The payload, the schema read
(UserPlatformProductSchema, which reaches the workspace through deliveryRequest()), the
404 handling and the deletion are in the
Outbound Writer reference: Products and in
ADR-0047. The
direction matrix lists products for Positive User as push_out, with overwrite and delete as the
delivery defaults.
Live Test Tier¶
Three opt-in files run against a real Positive User workspace:
UserPlatformLiveVerificationTest.php, and the two consent tiers UserPlatformLiveConsentTest.php
(read) and UserPlatformLiveConsentWriteTest.php (write). The verification tier is described first.
tests/Live/UserPlatformLiveVerificationTest.php runs verify() against a real Positive User
workspace. It exists because what UserPlatformIntegration verifies with came from user.com's
published API documentation, not from a running workspace, and one assumption in it cannot be
checked any other way: that GET /api/public/attributes/ exists, is authenticated, and answers for
a workspace without being handed a contact. The run makes read-only calls and creates nothing.
Like the PrestaShop and WooCommerce live tiers, it is registered in tests/Pest.php but not in
phpunit.xml's <testsuites>, so composer test and CI never discover it, and every test skips
rather than fails when its environment variables are unset. Run it explicitly:
docker compose exec \
-e USER_PLATFORM_API_HOST=staginguser.com \
-e USER_PLATFORM_LIVE_SUBDOMAIN=<workspace subdomain, without the apex> \
-e USER_PLATFORM_LIVE_API_TOKEN=<64-char token> \
php composer test:live -- --filter UserPlatformLiveVerification
See Run the Live Tests for the shared rules a live test follows and how the PrestaShop/WooCommerce tiers are set up; the Positive User tier needs no disposable shop, only a real workspace's subdomain and a token issued for it.
The consent tiers¶
Every endpoint, payload shape and timestamp format of the consent paths came from User.com's documentation. The two consent files turn that into measurement. The assumptions they check all fail silently: a consent written where the pull does not read it, or a timestamp the declared format cannot read, both surface as "no consent recorded", which is also a legitimate answer.
| File | What it measures |
|---|---|
UserPlatformLiveConsentTest.php |
The contact carries email_opt_in as a key; a fresh contact pulls as "never asked"; a raw email_opt_in write maps onto the Positive User mirror's identity and values; updated_at reads through DATETIME_FORMAT to the same instant; a deleted contact answers 404 |
UserPlatformLiveConsentWriteTest.php |
Through the writer's customer_consents update: PUT users/{id}/ {"email_opt_in": …} is kept; the app refuses a non-boolean unsubscribed_from_sms with 400; an SMS write's requests answer 2xx and leave email_opt_in and unsubscribed unchanged; POST users/{id}/events/ with consent_granted answers 2xx on the numeric id; the mass update moves unsubscribed both ways; the round trip agrees with pullConsent(); the write advances updated_at; a deleted contact is reported as RemoteRecordGoneException |
Both skip unless USER_PLATFORM_LIVE_SUBDOMAIN, USER_PLATFORM_LIVE_API_TOKEN and
USER_PLATFORM_API_HOST are all set. Because every consent path reaches only contacts Conflux
created, neither points at an existing contact. Each test creates its own through
UserPlatformOutboundWriter, records it in the ledger (createLiveContactFor() in tests/Pest.php)
and hard-deletes it in afterEach. A crashed run can leave a contact behind carrying
conflux_source = conflux-live-test.
docker compose exec \
-e USER_PLATFORM_API_HOST=staginguser.com \
-e USER_PLATFORM_LIVE_SUBDOMAIN=<workspace subdomain, without the apex> \
-e USER_PLATFORM_LIVE_API_TOKEN=<64-char token> \
php composer test:live -- --filter UserPlatformLiveConsent
No live test fires consent_requested
An unconfirmed subscription makes the writer fire consent_requested, which asks Positive User
to run its own double opt-in and sends a real email to the contact. Every consent the write
tier sends is a confirmed subscription or a withdrawal, so it always takes the consent_granted
branch, and its projection helper throws for anything else. Relaxing that is a decision to mail
the contact, to be taken for a test address only.
The events themselves are permanent. The contact is deleted after the run, but an event stays in the contact's stream, and any automation triggering on it will have run. Point the write tier at a workspace whose automations you know.
Out of Scope¶
- No connection widget or self-service flow. Positive User is an internal consuming platform,
not a merchant-installed plugin. There is deliberately no trigger for verification here — the
credentials reach Conflux by a route defined elsewhere, and the caller that will eventually run
verify()against a Positive User connection does not exist yet.RegisterSystemConnectionControllerandConnectSystemConnectionControllerare the plugin handshake the shop integrations use; Positive User never reaches them. This page documents the capability, not a UI action or an endpoint that calls it. - No credential rotation.
- No SMS pull and no SMS subscription read. The contact has no SMS opt-in, and its SMS flag is not readable over the API; see Consent Channels.
Test coverage¶
| Test file | Tier | What it covers |
|---|---|---|
tests/Unit/Integrations/UserPlatform/UserPlatformClientTest.php |
Unit (Http::fake(), no database) |
endpoint() composition and its independence from base_url; request()'s headers, including that acceptJson() is not used; validateCredentials()'s five refusals; deliveryRequest()'s status and credential guards; subdomain() on a connection with no credentials |
tests/Unit/Integrations/UserPlatform/UserPlatformIntegrationTest.php |
Unit (Http::fake(), no database) |
The registry resolves UserPlatformIntegration for SystemDriver::UserPlatform; performVerification()'s request, headers, and every row of the Outcomes table |
tests/Unit/Integrations/UserPlatform/UserPlatformConsentMapperTest.php |
Unit (pure) | Both read mappings (fromContact(), fromWebhook()), including the unsubscribed fallback, the SMS flag read only as a withdrawal and never by the pull, coerced value types, an empty or blank flag read as unknown, and a consent that cannot be placed in time; and the writer's callsFor() per consent state and channel |
tests/Integration/Integrations/UserPlatform/UserPlatformIntegrationTest.php |
Integration (database, Http::fake()) |
The ledger boundary on both paths, including a contact created in another workspace; the pull end to end, 404, non-2xx and a missing updated_at; the webhook writing the Positive User mirror, user_id as string or number, both timestamp shapes, a webhook older than the mirror skipped, and the skip-and-warn paths |
tests/Feature/Api/UserPlatformConsentWebhookE2eTest.php |
Feature (e2e) | POST /api/webhooks/{connection} → signature validation → ProcessWebhookJob → IntegrationRegistry → integration → ledger → ConsentRecorder; a connection given its secret by verification has its signed webhook accepted |
tests/Feature/Outbound/ConsentFlowE2eTest.php |
Feature (e2e) | The three consent flows through the real chain, including a Positive User withdrawal reaching WooCommerce and a second target, a confirmation that sends no second consent_requested, and a workspace connected to a shop with existing subscribers that asks for no double opt-in |
tests/Integration/Integrations/UserPlatform/UserPlatformVerificationE2eTest.php |
Integration (database-backed e2e) | SystemConnectionService::verify() → the real IntegrationRegistry → the real UserPlatformIntegration → Http::fake() at the HTTP boundary; asserts both the outbound request and VerificationResult::message directly, and that a verified connection is given a webhook secret and keeps one it holds |
tests/Unit/Services/AbstractIntegrationTest.php (describe('verify')) |
Unit | VerifiesConnections::verify() passes an integration-written message through untouched, and logs (rather than carries) a thrown exception's message |
tests/Live/UserPlatformLiveVerificationTest.php, UserPlatformLiveConsentTest.php, UserPlatformLiveConsentWriteTest.php |
Live, opt-in | See Live Test Tier above |
Source files¶
| File | Purpose |
|---|---|
app/Integrations/UserPlatform/UserPlatformIntegration.php |
performVerification(), performPullConsent(), processWebhook() |
app/Integrations/UserPlatform/UserPlatformProductMapper.php, UserPlatformProductSchema.php, UserPlatformErrorMessage.php |
The product payload and its custom_id, the workspace's product attribute names, and the error text of a response, all used by the writer |
app/Integrations/UserPlatform/UserPlatformConsentMapper.php |
What a consent is on a contact, in both directions: fromContact() and fromWebhook() for this integration, callsFor() for the writer, and the one channel list both declare |
app/Models/OutboundDelivery.php |
scopeCreatedAs(): the ledger lookup from a workspace's contact id back to the record it was created for |
app/Services/ConsentRecorder.php |
Writes the Positive User mirror |
app/Integrations/UserPlatform/UserPlatformClient.php |
validateCredentials(), subdomain(), endpoint(), request(), deliveryRequest() |
app/Exceptions/UnusableCredentialsException.php |
The one exception type whose message is written for whoever configured the connection, and may therefore be forwarded |
app/Providers/AppServiceProvider.php |
Registers UserPlatformIntegration against SystemDriver::UserPlatform in the IntegrationRegistry |
app/Services/Concerns/VerifiesConnections.php |
verify()'s exception-to-VerificationResult conversion, including the logging behaviour |
database/seeders/SystemSeeder.php |
Seeds the user-platform system's auth_fields (subdomain, api_token) |
See also¶
- Integration Contract reference —
AbstractIntegration,VerificationResult,IntegrationContract, andCanVerifyConnections - Integration Contract design — reasoning behind the verification and error-handling design, including why
verify()returns a result instead of throwing - Connection Flow — what
VerificationResultcarries and how it fits the registration handshake - WooCommerce Integration reference / PrestaShop Integration reference — the two integrations that read
base_urlas their host, for contrast - Run the Live Tests — the shared rules a live test follows
- ADR-0028 — why the subdomain, not
base_url, is the sole source of the host, and whyVerificationResult::messageis now curated for the caller that eventually reads it - ADR-0044 — why consent reaches only contacts the delivery ledger holds, and why it is mirrored on the Positive User connection
- ADR-0045 — the field, suppression and event flow of the consent write
- ADR-0047 — how a product is keyed, addressed and written into the workspace