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, 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