Skip to content

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:

  1. Verified credentials — the plugin must confirm that the supplied API key or token actually works against the live merchant store.
  2. 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 SystemConnection is 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):

  1. The plugin sends system_slug, base_url, and credentials.
  2. The endpoint verifies the credentials against the live system by calling IntegrationContract::verify() with a transient, unsaved SystemConnection. On failure the request is rejected with HTTP 422 and nothing is stored.
  3. On success, a pending SystemConnection is found or created, matched by system_id + base_url where tenant_id IS NULL and status = pending. Credentials are stored encrypted.
  4. 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.
  5. Returns HTTP 201 with { "connection_token": "...", "expires_at": "..." }. The merchant hands the token to the caller.

/connect with a token (caller finalizes):

  1. The caller — authenticated via auth:connection as its own SystemConnection (see API Authentication) — sends the connection_token.
  2. The token is looked up by hash. The linked connection must be pending, have tenant_id IS NULL, and have credentials IS NOT NULL — i.e. a plugin-created connection awaiting an owner. Any mismatch is rejected with 422.
  3. The stored credentials are re-verified. On failure the request returns 422 and the token is not consumed (the caller may retry).
  4. On success: tenant_id is set to the caller's own tenant (taken from its authenticated SystemConnection), external_user_id is taken from the verification result, status becomes active, last_connected_at is stamped, and the token is consumed. Returns HTTP 201 with the SystemConnectionResource.

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

  1. The caller — authenticated via auth:connection as its own SystemConnection — sends no body params beyond the optional connection_token; system_slug and base_url are not part of this endpoint's validation rules at all — if sent, they are silently ignored.
  2. A brand-new SystemConnection is always created, holding only tenant_id (taken from the caller's own authenticated connection) and status = pending. Both system_id and base_url are null — 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.
  3. Returns HTTP 201 with { "connection_token": "...", "expires_at": "..." }. The merchant pastes it into the plugin.

/register with a token (plugin finalizes):

  1. The plugin sends connection_token, system_slug, base_url, and credentials — all four are required, exactly as in the plugin-initiated flow.
  2. The token is looked up by hash. The linked connection must be pending, have credentials IS NULL, and have tenant_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's system_slug (not from the connection, since system_id is still null at this point).
  3. system_id, base_url, and credentials are 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).
  4. On success: system_id, base_url, and credentials are stored, external_user_id is set, status becomes active, last_connected_at is stamped, and the token is consumed. Returns HTTP 200 with the SystemConnectionResource.

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:

  1. Tokens are revoked. SystemConnectionService::revokeAllTokens() hard-deletes every SystemConnectionToken (handshake and widget access tokens alike) belonging to the connection.
  2. The plugin is notified, best-effort. NotifyConnectionDeactivatedJob is dispatched and, when the connection's integration implements App\Contracts\CanNotifyDeactivation, calls its notifyDeactivated() method to tell the plugin it was deactivated from the Conflux side. Failures are logged, not retried — the plugin may already be unreachable.
  3. Business data is soft-deleted. CleanupConnectionDataJob soft-deletes the connection's Customer, Order, LineItem, Product, and CustomerConsent rows.

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, and CustomerConsent rows, 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 than is_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 touches deleted_at, never is_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_id populated on the retired connection, so the unique index still considered that triple taken — the reactivating connection's own save() 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 only Active connections, via isHealthy().
  • 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 — only Active connections. There is no reason for a Pending, Error, Disconnected, Uninstalled, or Inactive connection to serve data, whether through the standard API or the widget. EnsureWidgetTokenIsAuthorizedForRoute sits between the two middlewares above and narrows which of these routes a connector-widget-named token specifically may reach — see System Connection Tokens — Route-level scope.
  • AuthenticateWidgetToken alone, with no status check at all, gates POST /system-connections/disconnect and POST /system-connections/uninstall. These two routes report an event rather than serve data, and the widget token retained through the Disconnected grace 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