Building API integrations
An integration translates what your application wants into an external API call. Give each operation a command or query of its own, so callers can say “set the target temperature” without knowing the URL, headers or JSON format.
Start with one operation
Section titled “Start with one operation”This teaching API accepts POST https://heating.example/target with { "celsius": 21.0 } and returns HTTP 204.
Its token comes from heating.api.token (HEATING_API_TOKEN in the environment).
public record SetHeatingTarget(double celsius) { @HandleCommand void handle() { var request = WebRequest.post("https://heating.example/target") .header("Authorization", "Bearer " + ApplicationProperties.requireProperty("heating.api.token")) .contentType("application/json") .body(new TargetTemperature(celsius)) .build(); var response = Fluxzero.sendWebRequestAndWait(request); if (response.getStatus() != 204) { throw new IllegalStateException( "Heating API returned HTTP " + response.getStatus()); } }}
public record TargetTemperature(double celsius) {}data class SetHeatingTarget(val celsius: Double) { @HandleCommand fun handle() { val request = WebRequest.post("https://heating.example/target") .header("Authorization", "Bearer " + ApplicationProperties.requireProperty("heating.api.token")) .contentType("application/json") .body(TargetTemperature(celsius)) .build() val response = Fluxzero.sendWebRequestAndWait(request) check(response.status == 204) { "Heating API returned HTTP ${response.status}" } }}
data class TargetTemperature(val celsius: Double)From an active application context, send SetHeatingTarget(21.0) through Fluxzero.sendCommandAndWait(...).
The self-handler runs locally without registration. Its HTTP request still uses the ordinary Fluxzero web gateway,
including tracing, audit and configured transport behavior. Do not invoke handle() directly.
An external read follows the same pattern with @HandleQuery and Request<YourReply>. The
web-request guide shows response decoding and transport settings.
Test the request at the HTTP boundary
Section titled “Test the request at the HTTP boundary”Run the actual command handler. A fixture HTTP handler supplies the remote response; no API-service mock or external account is needed:
@Consumer(name = "heating-api-stub")class HeatingApiStub { @HandlePost("https://heating.example/target") WebResponse setTarget(TargetTemperature target) { return WebResponse.builder().status(204).build(); }}
@Testvoid sendsTheRequestedTarget() { TestFixture.create(new HeatingApiStub()) .withProperty("heating.api.token", "test-token") .whenCommand(new SetHeatingTarget(21.0)) .expectSuccessfulResult() .expectOnlyWebRequests(WebRequest.post("https://heating.example/target") .header("Authorization", "Bearer test-token") .contentType("application/json") .body(new TargetTemperature(21.0)).build());}@Consumer(name = "heating-api-stub")class HeatingApiStub { @HandlePost("https://heating.example/target") fun setTarget(target: TargetTemperature): WebResponse = WebResponse.builder().status(204).build()}
@Testfun sendsTheRequestedTarget() { TestFixture.create(HeatingApiStub()) .withProperty("heating.api.token", "test-token") .whenCommand(SetHeatingTarget(21.0)) .expectSuccessfulResult() .expectOnlyWebRequests(WebRequest.post("https://heating.example/target") .header("Authorization", "Bearer test-token") .contentType("application/json") .body(TargetTemperature(21.0)).build())}The stub’s named consumer gives it independent processing when the fixture runs asynchronously.
The assertion checks the method, destination, credential header and payload. Change the stub’s response to test
rejected credentials or remote failures. Use TestFixture.createAsync(...) as well when the surrounding workflow
uses tracked handlers or retries.
Grow the integration without hiding the operations
Section titled “Grow the integration without hiding the operations”Once several operations share authentication and request settings, extract those conventions into a small helper,
interface or base class. Keep the concrete command/query responsible for its operation and result type.
For example, SetHeatingTarget and GetHeatingStatus can share header construction without becoming one generic
“call URL” command.
Keep credentials in ApplicationProperties. If operators configure different installations, let application state
select a trusted configuration group; do not accept arbitrary URLs or tokens in public domain commands. Provider
request types belong with the integration, while the domain’s API stays independent of that provider.
Translate responses into useful outcomes
Section titled “Translate responses into useful outcomes”The first example propagates an unexpected HTTP status as a technical failure. As the API grows, distinguish known refusals, temporary unavailability and malformed responses. Return or throw outcomes your workflow can act on, without copying remote bodies or credentials into user-facing errors. Unexpected programming failures should still surface as failures.
Choose timeouts and transport retries using
WebRequestSettings.
A retry must be appropriate for that operation: “set target to 21” can usually be repeated more safely than “increase
target by 1”. A timeout can mean the remote operation succeeded but its response was lost.
Connect external work to committed application state
Section titled “Connect external work to committed application state”If the API call follows a domain change, first commit that change. A tracked event handler can then send the
integration command. Keep external I/O out of @Apply, because applies can run again during replay or conflict recovery.
The HTTP call is not part of the Model transaction.
flowchart LR
C[Domain command] --> M[Commit application intent]
M --> H[Tracked handler]
H --> O[Integration command]
O --> A[External API]
A local integration command is one invocation, not a durable job by itself. For work that must survive interruption,
retain pending intent in a stateful workflow and let its
processing/retry policy drive the operation. Do not add @TrackSelf merely because the command sends HTTP.
An acknowledgement is not the final observation
Section titled “An acknowledgement is not the final observation”HTTP 204 says the heating API accepted the target. It does not prove that the room has reached 21 °C. Read observations separately and keep their meaning distinct from the requested setting. If a person later changes the physical controls, decide whether your product should follow that change or actively enforce a target. Do not accidentally restore an old, already completed request.
Repeating work must preserve its identity
Section titled “Repeating work must preserve its identity”For an external action that creates something, retain an operation ID before sending it and reuse the same ID when retrying. When the provider supports an idempotency key, map that retained ID to its request header. Generating a new key for each attempt creates new operations. Respect the provider’s retention window and use an authoritative lookup when the outcome is uncertain.
Test interruption after the external call as well as an explicit HTTP error. A successful response followed by a failed local acknowledgement is a different recovery case. The workflow decides when work is complete; the gateway’s transport retry cannot decide that business outcome.
Follow the pattern in the example applications
Section titled “Follow the pattern in the example applications”The public examples show these refinements with concrete product rules. These links point to the revisions reviewed for this guide:
- Home: the service command sends one operation; the endpoint helper shares configuration and status handling. Request tests exercise the actual HTTP contract. The integration boundary explains requested settings versus observed device state.
- Ticketing: a provider command uses shared request conventions. The workflow’s effect handler executes retained intent, and recovery tests cover interruptions after effects and acknowledgements.
© 2026 Fluxzero