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, ConnectSystemConnectionRequest has no validation rules for system_slug/base_url at all — 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.
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.
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
- 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