Skip to content

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

$registry->register(SystemDriver::UserPlatform, UserPlatformIntegration::class);

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:

https://<subdomain>.<apex>/api/public/<path>

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

  1. Calls UserPlatformClient::validateCredentials() and catches UnusableCredentialsException, returning VerificationResult::failure($e->getMessage()) without making a request. This is the one exception type whose message is forwarded rather than logged and replaced — see VerificationResult shape below.
  2. GETs /api/public/attributes/ — the workspace's own custom-attribute schema — authenticated via UserPlatformClient::request().
  3. Returns a VerificationResult based on the response status (see Outcomes below).
  4. On success, declares conflux_source where that list does not already name it (see Declaring conflux_source).

Endpoint

GET https://<subdomain>.<apex>/api/public/attributes/

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.

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

GET https://<subdomain>.<apex>/api/public/users/{id}/

{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

  1. Reads extra. A payload without an extra object is discarded with a warning.
  2. 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.
  3. Checks the ledger boundary. A contact Conflux did not create on this connection is skipped with a warning, and nothing is written or sent.
  4. Maps extra.email_opt_in and extra.unsubscribed_from_sms through UserPlatformConsentMapper::fromWebhook(), into up to two consents: email first, then SMS.
  5. 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 whose timestamp lies before the consent_updated_at its 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.

One automation per workspace, built in User.com by whoever operates the workspace:

  1. Trigger: the change of the contact's email_opt_in attribute, 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).
  2. Action: a "Send API Call" module, POST to https://<conflux host>/api/webhooks/{connection id}, where {connection id} is the Positive User connection's UUID.
  3. 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_sms is 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_id is the contact's numeric User.com id, not user_custom_id or the email, see the warning below. timestamp is accepted with or without a fractional second, with +00:00 or Z. 4. Header Signature: 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 with 403. 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.

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.

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. RegisterSystemConnectionController and ConnectSystemConnectionController are 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, and CanVerifyConnections
  • Integration Contract design — reasoning behind the verification and error-handling design, including why verify() returns a result instead of throwing
  • Connection Flow — what VerificationResult carries and how it fits the registration handshake
  • WooCommerce Integration reference / PrestaShop Integration reference — the two integrations that read base_url as 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 why VerificationResult::message is 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