Connection Flow¶
This page explains the reasoning behind the two-sided connection flow — how a plugin running inside a merchant's store and an already-connected consumer (rapidmail, User Platform, or any other pull-out consumer) collaborate via the Conflux API to establish a verified SystemConnection, and why the process is split across two endpoints.
For the exact endpoint specification, see the auto-generated API docs. For the data models involved, see System Connections and System Connection Tokens. For how the caller authenticates itself, see API Authentication.
The Problem¶
A SystemConnection needs two things before it can be used:
- Verified credentials — the plugin must confirm that the supplied API key or token actually works against the live merchant store.
- An owning Tenant — the connection must be linked to the Tenant (the real company) it belongs to.
The difficulty is that these two pieces of information arrive from different parties. The merchant's plugin knows the credentials; the caller knows only its own tenant, via the SystemConnection it is already authenticated as. Neither has both, and neither should need to trust the other blindly.
Two Endpoints, Two Sides¶
Each party talks to its own endpoint:
| Endpoint | Caller | Auth | Supplies |
|---|---|---|---|
POST /api/system-connections/register |
Plugin (in store) | unauthenticated | system_slug + base_url + credentials (always, in both flows) |
POST /api/system-connections/connect |
An already-connected caller (e.g. rapidmail, User Platform), authenticated as its own SystemConnection |
auth:connection + ensure.connection.active |
its own tenant_id (taken from its authenticated connection) only — never system_slug/base_url |
Each endpoint runs one of two flows, selected by the presence or absence of a connection_token in the request body:
- Without a token the caller initiates a handshake: a pending
SystemConnectionis created for the half it owns, and a short-lived token is returned. - With a token the caller finalizes a handshake that the other side already started: the connection is verified and activated.
A SystemConnectionToken is what bridges the two endpoints. The merchant copies it from whichever side initiated and pastes it into the other. Whoever initiates fills their half (credentials for the plugin, tenant_id for the caller); whoever finalizes supplies the missing half and triggers verification.
This yields two symmetric handshakes, distinguished by who starts.
Handshake 1 — Plugin-Initiated¶
The plugin starts from inside the merchant's store; the already-connected caller finalizes.
sequenceDiagram
participant Plugin as Plugin (in store)
participant Conflux
participant System as External System
participant Caller as Caller (authenticated SystemConnection)
Plugin->>Conflux: POST /register<br/>system_slug + base_url + credentials
Conflux->>System: verify(credentials) on transient connection
alt verification fails
System-->>Conflux: failure
Conflux-->>Plugin: 422 — nothing stored
else verification succeeds
System-->>Conflux: success
Conflux->>Conflux: find-or-create pending SystemConnection<br/>(tenant_id = null), store credentials
Conflux->>Conflux: invalidate prior tokens,<br/>issue new token (1h TTL)
Conflux-->>Plugin: 201 { connection_token, expires_at }
end
Plugin-->>Caller: merchant hands token to the caller
Caller->>Conflux: POST /connect (auth:connection)<br/>connection_token
Conflux->>Conflux: look up token, require tenant_id IS NULL<br/>and credentials IS NOT NULL
Conflux->>System: verify(stored credentials)
alt verification fails
Conflux-->>Caller: 422 — token not consumed (retryable)
else verification succeeds
System-->>Conflux: success + external_user_id
Conflux->>Conflux: set tenant_id = caller's own tenant_id,<br/>external_user_id, status = active, consume token
Conflux-->>Caller: 201 SystemConnectionResource
end
/register without a token (plugin initiates):
- The plugin sends
system_slug,base_url, andcredentials. - The endpoint verifies the credentials against the live system by calling
IntegrationContract::verify()with a transient, unsavedSystemConnection. On failure the request is rejected with HTTP 422 and nothing is stored. - On success, a pending
SystemConnectionis found or created, matched bysystem_id + base_urlwheretenant_id IS NULLandstatus = pending. Credentials are stored encrypted. - Prior valid tokens for that connection are invalidated and a new one is issued (1h TTL). Only the SHA-256 hash is stored; the plaintext is returned once.
- Returns HTTP 201 with
{ "connection_token": "...", "expires_at": "..." }. The merchant hands the token to the caller.
/connect with a token (caller finalizes):
- The caller — authenticated via
auth:connectionas its ownSystemConnection(see API Authentication) — sends theconnection_token. - The token is looked up by hash. The linked connection must be
pending, havetenant_id IS NULL, and havecredentials IS NOT NULL— i.e. a plugin-created connection awaiting an owner. Any mismatch is rejected with 422. - The stored credentials are re-verified. On failure the request returns 422 and the token is not consumed (the caller may retry).
- On success:
tenant_idis set to the caller's own tenant (taken from its authenticatedSystemConnection),external_user_idis taken from the verification result,statusbecomesactive,last_connected_atis stamped, and the token is consumed. Returns HTTP 201 with theSystemConnectionResource.
Handshake 2 — Tenant-Initiated¶
The caller starts from the API side; the plugin finalizes. Unlike Handshake 1, the caller contributes only its own tenant identity — it never knows (or supplies) system_slug/base_url. Those belong entirely to the plugin, which is the only side that knows which system and store it is running in.
sequenceDiagram
participant Caller as Caller (authenticated SystemConnection)
participant Conflux
participant Plugin as Plugin (in store)
participant System as External System
Caller->>Conflux: POST /connect (auth:connection)<br/>no body params
Conflux->>Conflux: create pending SystemConnection<br/>(tenant_id = caller's own tenant_id, system_id = null, base_url = null)
Conflux->>Conflux: issue token (1h TTL)
Conflux-->>Caller: 201 { connection_token, expires_at }
Caller-->>Plugin: merchant pastes token into plugin
Plugin->>Conflux: POST /register<br/>connection_token + system_slug + base_url + credentials
Conflux->>Conflux: look up token, require tenant_id IS NOT NULL<br/>and credentials IS NULL
Conflux->>System: verify(credentials)
alt verification fails
Conflux-->>Plugin: 422 — nothing stored,<br/>token not consumed (retryable)
else verification succeeds
System-->>Conflux: success + external_user_id
Conflux->>Conflux: store system_id, base_url, credentials,<br/>set external_user_id, status = active, consume token
Conflux-->>Plugin: 200 SystemConnectionResource
end
/connect without a token (caller initiates):
- The caller — authenticated via
auth:connectionas its ownSystemConnection— sends no body params beyond the optionalconnection_token;system_slugandbase_urlare not part of this endpoint's validation rules at all — if sent, they are silently ignored. - A brand-new
SystemConnectionis always created, holding onlytenant_id(taken from the caller's own authenticated connection) andstatus = pending. Bothsystem_idandbase_urlarenull— there is no find-or-create matching on this side, since the system/base_url key does not exist here yet. Unlike/register's initiate flow, there is no existing connection to reuse, so nothing is invalidated — a token is simply issued for the new row. - Returns HTTP 201 with
{ "connection_token": "...", "expires_at": "..." }. The merchant pastes it into the plugin.
/register with a token (plugin finalizes):
- The plugin sends
connection_token,system_slug,base_url, andcredentials— all four are required, exactly as in the plugin-initiated flow. - The token is looked up by hash. The linked connection must be
pending, havecredentials IS NULL, and havetenant_id IS NOT NULL— i.e. a tenant-created connection awaiting a system and credentials. Any mismatch is rejected with 422. The system is resolved from the request'ssystem_slug(not from the connection, sincesystem_idis stillnullat this point). system_id,base_url, andcredentialsare placed on the connection in memory only and verified. On failure nothing is persisted and the token is not consumed (the plugin may correct and retry).- On success:
system_id,base_url, andcredentialsare stored,external_user_idis set,statusbecomesactive,last_connected_atis stamped, and the token is consumed. Returns HTTP 200 with theSystemConnectionResource.
Why Verification Uses a Transient Connection¶
When a side supplies credentials, they are verified before being written to the database. The controller constructs (or reuses) a SystemConnection instance, sets the credentials in memory, and passes it to the integration's verify() method without calling save() first. A failed verification therefore leaves no trace — there is no partial record to clean up, and any token involved stays valid for a retry.
Why the Token Is Invalidated on Re-initiation¶
This applies to /register's plugin-initiated flow only. If the plugin re-registers for the same system_id + base_url (for example, a merchant re-runs the plugin setup to rotate credentials), the endpoint reuses the existing pending connection and issues a fresh token. Any previously issued token for that connection is invalidated at that point by setting used_at = now().
This guarantees that at most one valid token exists for a given pending connection, so a stale token cannot be redeemed after a re-registration.
/connect's tenant-initiated flow has no equivalent re-initiation: since it no longer carries a system_id + base_url key to match against, every call creates a brand-new SystemConnection with its own token. Repeated calls do not invalidate anything — they simply leave behind additional pending connections, which is why an automated cleanup for abandoned ones is needed (see Cleanup).
Why /register Is Unauthenticated¶
The plugin has no Bearer token when it registers — obtaining a working connection is a prerequisite for being issued one. So /register strips auth:connection and ensure.connection.active via withoutMiddleware.
The endpoint is deliberately narrow: it never returns existing connection data and never modifies an already-active connection. Its only side effects are creating or updating a single pending connection and issuing a short-lived token.
Because it is unauthenticated and each call triggers a live verification request to an external system, the route is protected by a per-client-IP rate limiter (throttle:connection-registration, defined in AppServiceProvider). The limit defaults to 10 requests per minute and is configurable via config/conflux.php (env CONNECTION_REGISTRATION_RATE_LIMIT). Exceeding it returns HTTP 429 in the standard application/problem+json shape with a Retry-After header.
Why /connect Is Authenticated¶
/connect keeps the standard API middleware (auth:connection + ensure.connection.active; see API Authentication). The caller's identity is the whole point of this side of the handshake, so it must be authenticated: the tenant_id written onto the connection is taken from the caller's own authenticated SystemConnection, never from the request body. A caller can therefore only create or claim connections for its own tenant, and it cannot impersonate another tenant — there is no tenant_id (or equivalent) request parameter to forge in the first place.
For the same reason, system_slug and base_url stay nullable in ConnectSystemConnectionRequest for both handshakes described here — the caller never owns that half of the handshake, on either flow. Only the plugin, via /register, knows which system and store it is running in. (system_slug, alongside external_user_id, does become required on a third, unauthenticated path — the connector widget's tenant-bootstrap call, which creates its own Tenant rather than joining either handshake above. See Connector Widget — Phase 1.)
Because every call is attributable to an authenticated, active connection, no IP rate limiter is applied — abuse is already bounded and traceable through the caller's credentials.
Deactivation and Reconnection¶
A SystemConnection is not only ever created and activated. It can also stop being Active in one of three distinct ways, each with its own consequences: deactivation (Inactive), a shop-initiated disconnect (Disconnected, with a 30-day grace period), and a shop-initiated uninstall (Uninstalled, immediate and final). Each end state is reached through its own transition class, Deactivate, Disconnect or Uninstall, which carries the matching clean-up. The same guarantee therefore holds regardless of which path (admin panel, plugin API call, or sibling retirement) caused the transition. A connection can also leave Active for Error, when the circuit breaker opens during a sync. Error is not an end: an operator moves the connection back with the admin panel's Reactivate action once the shop is reachable again. See ADR-0039 for why every status change runs through a transition. See ADR-0013 for why these are three separate states rather than variants of one.
Deactivation (Inactive)¶
A connection becomes Inactive through the admin panel's DeactivateConnectionAction, or as a side effect of SystemConnectionService::activate() retiring a sibling connection during reconnection (see Why Reconnection Must Retire the Old Connection below). There is no plugin-facing API endpoint for deactivation, and there is no admin action to move an Inactive connection back to Active from the panel (Reactivate applies only to Error). Reactivation happens only by completing a fresh handshake (see Two Endpoints, Two Sides above).
The Deactivate transition runs three steps, regardless of which path triggered the move to Inactive:
- Tokens are revoked.
SystemConnectionService::revokeAllTokens()hard-deletes everySystemConnectionToken(handshake and widget access tokens alike) belonging to the connection. - The plugin is notified, best-effort.
NotifyConnectionDeactivatedJobis dispatched and, when the connection's integration implementsApp\Contracts\CanNotifyDeactivation, calls itsnotifyDeactivated()method to tell the plugin it was deactivated from the Conflux side. Failures are logged, not retried — the plugin may already be unreachable. - Business data is soft-deleted.
CleanupConnectionDataJobsoft-deletes the connection'sCustomer,Order,LineItem,Product, andCustomerConsentrows.
Credentials (the credentials column) are not cleared by plain deactivation; only tokens are revoked. There is no grace period and no disconnected_at timestamp involved. Centralizing this clean-up in the transition, rather than in whichever action deactivates the connection, is what makes the guarantee hold "for any path". A future admin action, a console command, or a service call can change the status only through the transition, so all of them get the same clean-up for free. An ordinary save that changes the status any other way is rejected by the observer's saving() guard; query-builder writes, saveQuietly() and withoutEvents() bypass it and are forbidden by guideline. See System Connections: Deactivation for the full mechanics.
Disconnect (Disconnected, grace period)¶
The PrestaShop module and WooCommerce plugin can be disabled or disconnected from their own admin UI, entirely outside of Conflux — the merchant never has to visit Conflux's admin panel to stop a connection. POST /api/system-connections/disconnect is the plugin's counterpart: authenticated by the widget token as a Bearer header (the only credential the plugin still holds at that point, not the connection guard used by /connect), it sets status = Disconnected on the caller's own connection and returns. The admin panel offers the equivalent DisconnectConnectionAction. Deliberately POST, not DELETE — disconnecting is a business decision the plugin is reporting, the same framing as /connect, not a REST resource deletion. Unlike deactivation, disconnect does not end the connection outright: it starts a 30-day grace period.
The Disconnect transition cancels any in-progress bus batches and dispatches DisconnectConnectionDataJob, which:
- soft-deletes the connection's
Customer,Order,LineItem,Product, andCustomerConsentrows, exactly as deactivation does, and - soft-deletes the connection's
SyncRules too — deactivation leaves sync rules untouched, but disconnect withdraws them so nothing tries to sync during the grace period. Soft-delete rather thanis_active = false: that flag is an independent, admin-toggleable setting, and a rule an admin turned off before the disconnect must stay off on reconnect — restoring only ever touchesdeleted_at, neveris_active.
Credentials and tokens are kept — a reconnect within the window needs no new credentials from the operator. disconnected_at is stamped by SystemConnectionObserver::maintainDisconnectedAt(), a saving() hook that keeps the column an invariant of the state: set for exactly as long as status is Disconnected, and cleared the moment it leaves that status. SystemConnection::graceDeadline() / isWithinGracePeriod() derive the deadline from it without ever persisting it; the window defaults to 30 days and is configurable via conflux.disconnected_connection_grace_period_days (env DISCONNECTED_CONNECTION_GRACE_PERIOD_DAYS).
Reconnecting within the grace period. SystemConnectionService::activate() reactivates the existing Disconnected row itself (rather than activating a freshly created one) whenever a sibling with the same tenant_id/system_id/external_user_id is Disconnected (see Why Reconnection Must Retire the Old Connection). Moving it back to Active runs the Reconnect transition, which does what an ordinary activation does and additionally dispatches RestoreConnectionDataJob. The job restores only rows whose deleted_at is at or after disconnected_at, so data a reconcile run legitimately removed before the disconnect stays gone, and restores the connection's sync rules the same way, so one an admin had already turned off stays off.
Grace period expiry. SystemConnection implements Laravel's Prunable: prunable() selects Disconnected connections whose deadline has passed, and pruning() — the same FK-safe cascade delete PurgeConnectionJob uses for uninstall — hard-deletes everything belonging to the connection before prune() force-deletes the row itself. The daily model:prune --model=SystemConnection schedule entry (routes/console.php) runs this in chunks of 1000 with per-row fault isolation; a row that fails is reported and retried the next day rather than stopping the run. pruning() re-reads the connection's own state before touching anything and refuses (throwing ConnectionNotPurgeable) a connection that is Disconnected and still within its grace period, so a reconnect racing the batch is handled safely.
Uninstall (Uninstalled, immediate and final)¶
When the plugin is physically removed from the merchant's store — not merely disabled — the operator is explicitly requesting that no personal data remain in Conflux. POST /api/system-connections/uninstall is the uninstall counterpart to /disconnect: authenticated by the same widget token, it sets status = Uninstalled on the caller's own connection and returns. The admin panel offers the equivalent UninstallConnectionAction. Deliberately POST, not DELETE, for the same reason as /disconnect.
The Uninstall transition cancels in-progress bus batches, then, synchronously and before the endpoint returns, calls SystemConnectionService::revokeAllTokens() and SystemConnectionService::clearCredentials() (wipes the credentials column via updateQuietly). Finally it dispatches PurgeConnectionJob, which is queued once the transaction commits.
PurgeConnectionJob hard-deletes, using withTrashed() so already-soft-deleted rows are included too, in FK-safe order: LineItem/Order, CustomerConsent/Customer, Product, SyncBatch, SyncRule, SystemConnectionToken, WebhookCall, and finally the SystemConnection row itself. This makes Uninstalled short-lived by design: once the job completes, the row it was set on no longer exists — during the window between the endpoint returning and the job completing, the Uninstalled status records the reason the connection is ending. PurgeConnectionJob is the single shared hard-delete mechanism for both uninstall and grace-period expiry: it re-checks status before purging, only proceeding for Uninstalled connections or Disconnected ones past their deadline.
This is the key difference from disconnect: disconnect soft-deletes business data and keeps credentials for a possible reconnect, while uninstall hard-deletes everything and wipes credentials immediately. This distinction is why Uninstalled and Disconnected are separate statuses rather than both landing on Inactive — see ADR-0013.
Plugins call both /disconnect and /uninstall best-effort — a failed or unreachable call is logged locally and never blocks the plugin's own local teardown or uninstall routine. See System Connections — Disconnect and System Connections — Uninstall for the mechanics and the comparison tables.
Why Reconnection Must Retire the Old Connection¶
SystemConnectionService::activate() is called both when a brand-new connection completes its handshake and when a store's plugin re-registers using credentials that resolve to the same external_user_id on the same system, for a store that has connected under this tenant before. In the latter case, the newly activating connection and the old one would otherwise both hold the same (tenant_id, system_id, external_user_id) triple — which is protected by a unique index — so the old connection must be retired first.
This used to be done with a single bulk update() query that only changed status. That was not enough:
- It left
external_user_idpopulated on the retired connection, so the unique index still considered that triple taken — the reactivating connection's ownsave()then collided with it and failed. - A bulk query update bypasses Eloquent model events entirely, so
SystemConnectionObserver::updated()never ran for the retired connection. Its tokens and business data were never cleaned up — deactivation stopped meaning what Deactivation above describes it should mean, for exactly the connections most likely to be retired this way.
The fix addresses both. Siblings are retired one at a time. Each sibling's external_user_id is cleared to null and saved first, so the triple is genuinely freed before the reactivating connection saves. The sibling then moves to Inactive through the Deactivate transition, so its clean-up runs. A sibling that is already Inactive, or that ended deliberately (Disconnected, Uninstalled), keeps its state and its grace-period or purge bookkeeping. The whole handshake runs in one transaction, so the retirements roll back if the activation fails.
Why the Widget's Lifecycle Endpoints Are Looser Than Everything Else¶
Three tiers of middleware gate access to a connection, and only one of them is looser than the rest:
EnsureSystemConnectionIsActive(the standard API guard, see API Authentication) allows onlyActiveconnections, viaisHealthy().AuthenticateWidgetToken+EnsureWidgetConnectionIsActive(the widget's own guard on/widget/configuration,/widget/sync-batches,/widget/connections/{connection}/status, and/widget/external-credentials, see Connection Widget) allow the same — onlyActiveconnections. There is no reason for aPending,Error,Disconnected,Uninstalled, orInactiveconnection to serve data, whether through the standard API or the widget.EnsureWidgetTokenIsAuthorizedForRoutesits between the two middlewares above and narrows which of these routes aconnector-widget-named token specifically may reach — see System Connection Tokens — Route-level scope.AuthenticateWidgetTokenalone, with no status check at all, gatesPOST /system-connections/disconnectandPOST /system-connections/uninstall. These two routes report an event rather than serve data, and the widget token retained through theDisconnectedgrace period is a merchant's only way to send either signal — see System Connections — Disconnect and Uninstall for why gating them the same way as the data routes would leave a merchant stuck mid-uninstall-following-a-disconnect with no way to signal it.
Defending Against In-Flight Work¶
Token revocation is synchronous for deactivation and uninstall, but the queue is not instantaneous, so a webhook or a bulk sync job can already be queued at the moment a connection stops being Active, whether by deactivation, disconnect, or uninstall. BulkSyncJob, FetchBulkPageRangeJob, PersistBulkDataJob, ProcessWebhookJob, and FetchDeletionLogJob each re-check isHealthy() when they actually run (not just at dispatch time) and discard or return early if the connection is no longer Active. A job that started while the connection was still Active and then sees the circuit breaker open records Error through the RecordError transition. That transition re-checks the stored state under a row lock, so a connection that ended meanwhile keeps its end state instead of being overwritten with Error. See Webhook Receiver and Scheduled Sync.
What VerificationResult Carries¶
Integrations implement App\Contracts\IntegrationContract and are resolved per SystemDriver by App\Services\IntegrationRegistry. The verify() method returns an App\Data\VerificationResult. The successful boolean governs whether the flow proceeds; externalUserId is extracted from the result and stored on the connection to record which account on the external system was authenticated.
The shared mechanics behind both endpoints — issuing and rotating tokens, resolving and verifying via the integration, and activating a connection — live in App\Services\SystemConnectionService. Each controller keeps only its flow-specific orchestration (which state it considers finalizable, which half it fills, and its response code).
See also¶
- System Connections — model attributes, statuses, scopes, and the Deactivation mechanics
- System Connection Tokens — token hashing, TTL, helper methods
- Integration Contract —
verify()and the broader integration design - API Authentication — how standard API routes are protected
- API Errors — RFC 9457 error shape used by all 422 responses
- Connection Widget —
AuthenticateWidgetTokenand the widget's own token type - System Connections admin resource: the Reactivate, Deactivate, Disconnect, and Uninstall actions in the admin panel
- ADR-0013 — why disconnect and uninstall are modelled as distinct states rather than both landing on
Inactive