Skip to content

API-based Integration Patterns

This document covers the core patterns and code organization for API-based integrations that connect to external services via HTTP, OAuth, or SOAP.

Each API-based integration follows this structure:

app/Integrations/[Vendor]/
├── Api.php # API client wrapper (optional, for complex APIs)
├── Connector.php # Transport: authentication + HTTP calls
├── Integrator.php # Core sync logic, implements EntityProvider
├── Importer.php # Household import logic
├── Sync.php # Sync handler (legacy pattern)
├── Config.php # Integration configuration
├── Models/ # Vendor-specific data models
│ ├── Account.php
│ ├── Holding.php
│ ├── Transaction.php
│ └── ...
├── Exceptions/ # Vendor-specific exceptions
└── OAuth2/ # OAuth configuration (if OAuth-based)
└── Provider.php
Connector (abstract base)
└── ConnectorWithIntegration (abstract, has Integration model)
├── HttpConnectorWithIntegration
├── OauthConnectorWithIntegration
└── SoapConnectorWithIntegration

Location: app/Integrations/Support/LegacyApiBased/Connectors/

abstract class Connector
{
// Logging utilities for debugging
protected static function logToFile(?string $reference, string $postfix, array $data): void;
}

Base class for integrations that have an Integration model instance.

abstract class ConnectorWithIntegration extends Connector
{
public function __construct(protected Integration $integration) {}
// Make API call to vendor
abstract public function call(string $relative_url): array;
// Verify stored credentials are still valid
public function verifyIntegrationCredentials(): bool;
// Prevent duplicate integrations per advisor
protected function checkIfMoreThanOneIntegration(): void;
}

For REST APIs with API Key or Token authentication.

class HttpConnectorWithIntegration extends ConnectorWithIntegration
{
use UsesHttpTransport;
public function call(string $relative_url): array
{
// Build request with auth headers
// Make HTTP call via Guzzle
// Handle response/errors
}
}

Used by: Blueleaf, Commonwealth, Wealthbox, Redtail, etc.

For OAuth 2.0 flows with token refresh.

class OauthConnectorWithIntegration extends ConnectorWithIntegration
{
use UsesHttpTransport;
protected ThreadSafeOAuthTokenManager $token_manager;
public function call(string $relative_url): array
{
// Check token expiry
// Refresh if needed (thread-safe)
// Make authenticated request
}
public function getAccessToken(): AccessToken;
public function refreshAccessToken(): AccessToken;
}

Used by: Schwab API, Addepar, Orion, Capitect, Advyzon, etc.

Supporting Classes:

  • ThreadSafeOAuthTokenManager - Handles concurrent token refresh safely
  • JsonAuthOptionProvider - Custom OAuth option provider
  • OauthSamlProvider - SAML-based OAuth provider
  • OauthBasicAuthWithoutRefreshToken - Basic auth variant

For legacy SOAP/XML-RPC services.

class SoapConnectorWithIntegration extends ConnectorWithIntegration
{
use UsesSoapTransport;
protected SoapClient $client;
public function call(string $method): array
{
// Build SOAP envelope
// Make SOAP call
// Parse XML response
}
}

Used by: Tamarac

packages/libs/integrations-core/src/Integrators/Integrator.php
abstract class Integrator
{
public function __construct(protected Integration $integration) {}
public function getIntegration(): Integration
{
return $this->integration;
}
// Sync all linked accounts, remove disappeared, restore re-appeared
abstract public function syncAll(): void;
// Sync single mapping, return imported data
abstract public static function sync(IntegrationMapping $mapping): array|null;
// Get vendor type
abstract protected static function getVendor(): IntegrationType;
// Debug logging
protected static function logToFile(?string $reference, string $postfix, array $data): void;
}

Integrators that can list available entities implement this interface:

packages/libs/integrations-core/src/Integrators/EntityProvider.php
interface EntityProvider
{
/**
* List entities with optional search and cursor-based pagination.
*
* @param array<string,mixed> $conditions Search conditions
* @param string|null $cursor Pagination cursor, null = first page
* @return CursorPage<TEntity> Page of entities with next cursor
*/
public function listEntities(array $conditions, ?string $cursor = null): CursorPage;
}
packages/libs/integrations-core/src/Integrators/CursorPage.php
final readonly class CursorPage
{
public function __construct(
public array $data, // list<TEntity>
public ?string $next_cursor = null
) {}
}
app/Integrations/Wealthbox/Integrator.php
class Integrator extends \RightCapital\IntegrationsCore\Integrators\Integrator
implements EntityProvider
{
protected Connector $connector;
public function __construct(Integration $integration)
{
parent::__construct($integration);
$this->connector = new Connector($integration);
}
// List available contacts from Wealthbox
public function listEntities(array $conditions, ?string $cursor = null): CursorPage
{
$response = $this->connector->call('/contacts', [
'search' => $conditions['search'] ?? null,
'page' => $cursor,
]);
return new CursorPage(
data: array_map(fn($c) => [
'reference' => $c['id'],
'name' => $c['name'],
'email' => $c['email'],
], $response['contacts']),
next_cursor: $response['next_page'] ?? null
);
}
public function syncAll(): void
{
foreach ($this->integration->getRootIntegrationMappings() as $mapping) {
static::sync($mapping);
}
}
public static function sync(IntegrationMapping $mapping): ?array
{
$connector = new Connector($mapping->integration);
$contact = $connector->call("/contacts/{$mapping->reference}");
// Map vendor data to internal models
// Update Account, Insurance, etc.
// Update mapping->last_completed_at
return ['households' => [...], 'accounts' => [...], 'persons' => [...]];
}
protected static function getVendor(): IntegrationType
{
return IntegrationType::WEALTHBOX;
}
}

Used by: Schwab API, Addepar, Orion, Capitect, Advyzon, Allianz API

app/Integrations/SchwabApi/OAuth2/Provider.php
// OAuth2 Provider configuration
class Provider extends AbstractProvider
{
public function getBaseAuthorizationUrl(): string
{
return 'https://api.schwab.com/oauth/authorize';
}
public function getBaseAccessTokenUrl(array $params): string
{
return 'https://api.schwab.com/oauth/token';
}
protected function getDefaultScopes(): array
{
return ['accounts', 'positions'];
}
}

Flow:

  1. User redirected to vendor authorization URL
  2. User grants access, redirected back with code
  3. Exchange code for access_token + refresh_token
  4. Store tokens in integration.credentials
  5. Use access_token for API calls
  6. Refresh when expired (ThreadSafeOAuthTokenManager)
// Connector generates JWT for each request
class Connector extends ConnectorWithIntegration
{
private function generateJwt(): string
{
$payload = [
'iss' => $this->integration->credentials['issuer'],
'sub' => $this->integration->credentials['user_id'],
'iat' => time(),
'exp' => time() + 3600,
];
return JWT::encode($payload, $this->getPrivateKey(), 'RS256');
}
public function call(string $url): array
{
return Http::withToken($this->generateJwt())
->get($this->baseUrl . $url)
->json();
}
}

Used by: Wealthbox, Redtail, Betterment

class Connector extends HttpConnectorWithIntegration
{
protected function getAuthHeaders(): array
{
return [
'Authorization' => 'Bearer ' . $this->integration->credentials['api_key'],
// or
'X-Api-Key' => $this->integration->credentials['api_key'],
];
}
}
class Connector extends HttpConnectorWithIntegration
{
protected function getAuthHeaders(): array
{
$credentials = base64_encode(
$this->integration->credentials['username'] . ':' .
$this->integration->credentials['password']
);
return ['Authorization' => 'Basic ' . $credentials];
}
}

Each integration defines vendor-specific models that mirror the API response structure:

app/Integrations/Wealthbox/Models/Contact.php
class Contact
{
public function __construct(
public string $id,
public string $name,
public ?string $email,
public array $accounts,
) {}
public static function fromApiResponse(array $data): self
{
return new self(
id: $data['id'],
name: $data['first_name'] . ' ' . $data['last_name'],
email: $data['email'] ?? null,
accounts: $data['accounts'] ?? [],
);
}
}
// packages/libs/integrations-core/src/Models/
Account // Base account model with traits
Insurance // Base insurance model with traits
Holding // Position/holding model
// Account type traits
BankAccountTrait
CardAccountTrait
InvestmentAccountTrait
LoanAccountTrait
// Insurance type traits
LifeInsuranceTrait
DisabilityInsuranceTrait
AutoInsuranceTrait
LongTermCareInsuranceTrait

Exception Hierarchy (from integrations-core)

Section titled “Exception Hierarchy (from integrations-core)”
Exception (abstract base)
├── ExternalServiceException (vendor API errors)
├── ConnectException // Connection failed
├── TimeoutException // Request timeout
├── UnauthorizedException // Auth failed (401)
├── ForbiddenException // Access denied (403)
├── NotFoundException // Entity not found (404)
├── TooManyRequestException // Rate limited (429)
├── ServiceUnavailableException // Vendor down (503)
└── UnrecognizedException // Unknown error
├── FileDeliveryException // File send failed
├── FileNotFoundException // File not found
├── FileOutdatedException // File too old
└── UnexpectedValueException // Invalid data

Indicates whether the error affects the entire integration or just one mapping:

enum AffectedLevel: string
{
case Integration = 'integration'; // Whole integration broken
case IntegrationMapping = 'mapping'; // Single mapping failed
}
public static function sync(IntegrationMapping $mapping): ?array
{
try {
$connector = new Connector($mapping->integration);
$data = $connector->call("/accounts/{$mapping->reference}");
// ... process data
} catch (UnauthorizedException $e) {
// Credentials expired - mark integration as failed
throw new ExternalServiceException(
integration: $mapping->integration,
affected_level: AffectedLevel::Integration,
message: 'Credentials expired, please reconnect',
previous: $e
);
} catch (NotFoundException $e) {
// Account deleted on vendor side - soft delete mapping
$mapping->deleteByUserManually();
return null;
}
}