Integration Contract Design¶
This page explains the reasoning behind the integration communication contract — the design decisions that shape how platform integrations interact with Conflux: credential verification (CFX-53), outbound bulk exports (CFX-45), and inbound webhook processing (CFX-48).
For the exact field-by-field specification, see the Integration Contract reference.
What the Contract Is¶
When Conflux needs data from an external system (WooCommerce, PrestaShop), it calls through an integration. The integration is the only part of the system that knows the system's native API. Everything above the integration works with normalised Conflux DTOs.
The contract enforces that boundary. CanExportInBulk's methods (requestBulkExport(), streamBulkExport(), probeBulkExport(), fetchBulkExportPageRange()) are the entry points an integration exposes for the pull-in direction, the one where Conflux fetches from the system; all of them ultimately deal in a typed collection of DomainRecord, Conflux's inbound DTO type (see SystemCapability::dataClass() for the fixed set of concrete domains it stands for). Conflux never receives raw system objects.
Bulk export, like webhooks and on-demand consent, is an optional capability rather than part of the core IntegrationContract — see Optional Capability Interfaces for why it's split out and how callers check for it.
System-Agnostic by Design¶
The data types (DTOs) carry a sourceSystem field of type SystemDriver. This means consumers (rapidmail, User Platform, or any other downstream service) receive a single, uniform object shape regardless of which system produced it. The system identity travels as data on the DTO, not as a variant of the type itself.
An alternative would be system-specific DTOs (e.g. WooCommerceCustomerData). This was avoided deliberately. Downstream consumers would then need to branch on type, which scatters system awareness throughout the codebase and makes adding a new system a cross-cutting change.
The sourceId / id Split¶
Every entity has two identity fields:
sourceId— the system-native ID, always a string. This is the dedup key: if Conflux receives the same record twice, it matches onsourceId+sourceSystem.id— the Conflux-assigned UUID, nullable. It isnullwhen the integration returns the record, because Conflux has not yet assigned or looked up the internal identity. It is populated by Conflux after the record is persisted or looked up.
This split keeps the contract honest. An integration cannot know whether a record already exists in Conflux, so requiring id would force either a database lookup inside the integration (wrong layer) or a fabricated UUID (wrong data). Nullable id makes the lifecycle explicit: the integration is a producer, not an authority on Conflux identity.
The same pattern applies to cross-entity references. OrderData.customerId is the Conflux UUID of the customer (null until resolved); OrderData.customerSourceId is the platform-native customer ID (usable immediately for linking during import).
Monetary Values as Minor Currency Units¶
All monetary amounts are stored as integers in the smallest unit of the currency — cents for EUR/USD, pence for GBP, and so on. 1299 means €12.99.
Floating-point arithmetic on money is lossy. A float field invites rounding errors that compound across sums. Using integers eliminates the ambiguity entirely: the value is exact, and the interpretation (divide by 100 for display) is left to the consumer. The currency field travels alongside so that a consumer knows which denomination applies.
customAttributes as an Escape Hatch¶
Every entity includes a customAttributes: Collection<int, CustomAttributeData> collection. Systems expose fields that do not fit the normalised schema — a WooCommerce custom meta field, a PrestaShop custom attribute. Rather than modelling every system-specific field in the DTO (which would make the DTO a union of all systems), integrations map them into customAttributes.
Each entry is a CustomAttributeData with a key (string) and a typed value (string|int|bool|null). The convention is to namespace keys to avoid collisions (e.g. woocommerce_meta_loyalty_points). Downstream consumers that care about a specific attribute can extract it by key; consumers that do not care ignore the collection.
Error Handling at the Contract Boundary¶
requestBulkExport() (and its streaming/paged siblings streamBulkExport(), probeBulkExport(), fetchBulkExportPageRange()) are final on the ExportsInBulk trait, not on AbstractIntegration — the base class declares no capability methods at all. Integration authors implement the trait's abstract protected fetchBulkExportBatches() instead (optionally overriding performProbeBulkExport()/performFetchBulkExportPageRange() for parallel fan-out). Bulk export is opt-in by construction: an integration that does not use the trait has no bulk-export methods to bypass, and one that does has to declare implements CanExportInBulk for any caller's instanceof check to reach it. The trait wraps the call:
- If
fetchBulkExportBatches()raises anIntegrationRequestException(or subclass), it propagates unchanged. This covers expected conditions: authentication failures, rate limiting, resource not found. - Any other
Throwableis caught and re-thrown asInvalidIntegrationResponseException. This covers unexpected failures: null pointer exceptions, malformed API responses, deserialisation errors that the integration did not anticipate.
Callers of requestBulkExport() only need to handle IntegrationRequestException. The distinction between "the integration reported an error" and "the integration crashed unexpectedly" is preserved by the exception hierarchy (InvalidIntegrationResponseException extends IntegrationRequestException), but both cases are catchable at the same level.
Sealing requestBulkExport() as final on the trait ensures this guarantee holds across every integration that has the method at all — it cannot be accidentally bypassed by the class that uses it.
Circuit Breaker for Outbound Requests¶
The same four outbound template methods (verify(), streamBulkExport(), probeBulkExport(),
fetchBulkExportPageRange()) are also wrapped in a per-SystemConnection circuit breaker, so that
a failing or degraded external system stops receiving repeated requests instead of being hammered
by every retry and scheduled job. When the breaker actually opens during a bulk sync, the affected
SystemConnection is moved to Error through the RecordError transition, taking it out of scheduled
and manual sync until an operator reactivates it in the admin panel or the owner reconnects. See ADR-0009
for the design rationale, the build-vs-buy decision, and the connection-status side effect.
Why verify() Returns a Result Instead of Throwing¶
verify() (CFX-53) does not follow the throw/catch pattern above. VerifiesConnections::verify() catches any Throwable from performVerification() and converts it directly into VerificationResult::failure() — it never lets an exception escape to the caller.
This is deliberate: verify() sits on the synchronous request path, called directly from SystemConnectionService while handling /register and /connect. The controller needs a uniform, non-throwing outcome it can turn into either a 201/200 response or a 422 — introducing a second exception type to catch on this path would duplicate the branching that VerificationResult::successful already provides. Bulk export and webhook handling, by contrast, run inside queued jobs where a thrown, typed exception is the natural way to fail a job and land it in the failed_jobs table.
The conversion does not carry the exception's own message. VerifiesConnections::verify() logs it (connection id, integration class, exception class, message) and returns a fixed VerificationResult::failure('The external system could not be reached.') instead — an Illuminate\Http\Client\RequestException message carries the request URL and the response body, which is exactly the detail ApiErrorCode::detail() exists elsewhere in the API to keep out of a response a merchant can read. VerificationResult::message is therefore a field only integration code writes deliberately, in a sentence meant for whoever configured the connection: performVerification() returns VerificationResult::failure($message) for a failure it can name (a rejected token, a host that answers nothing), and simply throws for one it cannot anticipate. No caller reads message today — every registered controller and the Filament connect action answer a failed /register or /connect with the same fixed string regardless of it — so this has no visible effect yet, but it means the field is already safe to surface once one does.
See Connection Flow for how verify() fits into the two-sided registration handshake.
Why OrderData.status Is a String¶
Customer status and product status are modelled as enums (Gender, ProductStatus). Order status is a plain string. This is intentional.
Order statuses are highly system-specific and have no stable cross-system vocabulary. WooCommerce uses processing, completed, on-hold; PrestaShop represents status as a shop-configurable numeric current_state, resolved to its human-readable name. Defining a shared enum would require either a lossy mapping (collapsing distinct states) or a very large enum that grows with every new system. The raw system status is preserved so that downstream consumers can apply their own interpretation.
Inbound Webhooks: handleWebhook() / processWebhook()¶
CFX-48 added the inbound direction to the contract. When an external platform sends a webhook to POST /api/webhooks/{connection}, the payload eventually reaches the integration's processWebhook() method.
The design mirrors the outbound pattern: handleWebhook() is final on the HandlesWebhooks trait and wraps processWebhook() with the same error-handling guarantee. Any unexpected Throwable is caught and re-thrown as InvalidIntegrationResponseException. Integration authors implement processWebhook(), never handleWebhook().
Like fetchBulkExportBatches(), processWebhook() is abstract protected on its trait rather than a virtual method with a throwing default. Webhook support is opt-in through the trait: an integration that only handles bulk exports never mentions webhooks at all — no empty method to stub, and no capability to declare, since handleWebhook() lives on CanHandleWebhooks rather than on the core IntegrationContract. ProcessWebhookJob checks instanceof CanHandleWebhooks before calling handleWebhook(): for an integration without the capability, the job logs a warning and discards the webhook.
The earlier shape put every capability on AbstractIntegration as a virtual method whose default threw BadMethodCallException. That default was the only thing standing between a caller and a method the integration had never implemented, and it could only fail at runtime. Moving each capability into a trait with an abstract hook removes the state it guarded against: a class either has the method because it took the trait, or does not have it at all — see ADR-0023.
Why signature validation lives outside the integration¶
HMAC-SHA256 signature validation is handled by WebhookController, before the payload reaches the queue or the integration. Integrations never see unsigned or mis-signed requests. This separation keeps platform-specific logic out of the security boundary — every webhook is validated identically, regardless of which platform sent it. An integration cannot accidentally skip or weaken signature checks.
Why processing is asynchronous¶
The controller dispatches ProcessWebhookJob and returns 200 immediately. This keeps the webhook response time predictable for the sending platform (which may have tight timeout requirements) and decouples ingestion from processing. If the integration's processWebhook() fails, the job enters the failed-job table and can be retried — the external platform does not need to resend.
What the Contract Does Not Cover¶
- Pagination —
requestBulkExport()returns aCollection. How the integration fetches pages internally is an implementation detail. The contract does not model cursors or page tokens; if a platform requires pagination, the integration resolves it before returning the complete collection.
Delta Sync: modifiedAfter¶
ExportRequest carries an optional modifiedAfter instant (?CarbonImmutable, in UTC). When present, it signals to the integration that only records modified after this instant need to be returned. When absent, the integration fetches all records.
The cursor value is derived and injected by BulkSyncJob — integration authors do not compute it. An integration that targets a platform with server-side date filtering (such as WooCommerce) renders the instant to a string and sends it as a query parameter, in whatever format that platform reads. An integration targeting a platform with no filtering support can choose to filter the response in memory, or ignore the field and always perform a full fetch.
For the full design rationale, see Delta Sync.