Incentive API reference →

Java SDK

FynxtCrmClient is a small Java client for the CRM Onboarding APIs. Use it from your CRM backend to register CRM users, create their IB, Affiliate and Trader profiles, and attach their trading accounts so the FYNXT Incentive Program can attribute trading activity and pay out rebates and rewards. Once your CRM has paid a settlement, use it to report the payout outcome back to FYNXT.

Install

The SDK is a single source file with no FYNXT runtime. Copy FynxtCrmClient.java into your codebase and move it to a package of your choice. It needs Java 17+ (it uses java.net.http.HttpClient and records) and Jackson for JSON:

<dependency>
  <groupId>com.fasterxml.jackson.core</groupId>
  <artifactId>jackson-databind</artifactId>
  <version>2.17.2</version>
</dependency>
<dependency>
  <groupId>com.fasterxml.jackson.datatype</groupId>
  <artifactId>jackson-datatype-jsr310</artifactId>
  <version>2.17.2</version>
</dependency>

Spring Boot projects already include both. There is no Maven artifact to download.

Authentication

Every call carries your tenant's CRM API key in the X-Tenant-Api-Key header. The key identifies your brokerage on its own. The client adds the header for you.

X-Tenant-Api-Key: {{tenantCrmApiKey}}

Contact FYNXT to have a CRM API key issued for your brokerage. Keep it on the server: it must never reach a browser or mobile app.

Initialize the client

Build one client and reuse it. It is thread-safe and holds a single HttpClient connection pool.

FynxtCrmClient fynxt = FynxtCrmClient.builder()
    .baseUrl("https://incentive-program-app.fynxt.com")
    .tenantApiKey(System.getenv("FYNXT_CRM_API_KEY"))
    .actor("1", "CRM", "crm@yourbroker.com", "Broker")
    .build();
baseUrl
String
required
FYNXT Incentive host for your environment. Your FYNXT contact gives you the UAT host.
tenantApiKey
String
required
Your tenant's CRM API key, sent as X-Tenant-Api-Key.
actor
id, name, email, role
optional
Who made the change, recorded in the FYNXT audit trail as actionDoneById, actionDoneByName, actionDoneByEmail and actionDoneByRole. The client fills these into every request that does not set its own. Set a request's own actor when a CRM operator, not the system, made the change.
timeout
Duration
optional
Per-request timeout. Defaults to 30 seconds.

Onboarding flow

Onboarding a client into the Incentive Program takes three steps, in this order:

  1. Create the CRM user. A CRM user (CRM account) is the person or company in your CRM, keyed by your crmAccountId. It holds one or more profiles.
  2. Create their profiles. Each profile is an IB, Affiliate or Trader role for that user, keyed by your externalId. One CRM user can hold several, for example an IB who also trades. Steps 1 and 2 are one call: upsertAccountProfiles.
  3. Attach trading accounts. Link each trading-server login to the Trader profile that owns it and the IB or Affiliate who referred it, with attachTradingAccount. From then on, that login's trades count toward the referrer's programs.
Onboard referrers first. A Trader's referredById and a trading account's externalReferredById must name a profile FYNXT already knows. Upsert the IB or Affiliate before the clients they referred.

Create a CRM user & profiles

upsertAccountProfiles(request) creates the CRM user if crmAccountId is new and creates or updates each profile in profiles. Sending the same crmAccountId again updates the existing user. Calls POST /api/abstraction/crm/upsert-account-profiles and returns the CrmAccount.

CrmAccount account = fynxt.upsertAccountProfiles(
    new UpsertAccountProfilesRequest()
        .crmAccountId("CRM-USER-00001")
        .crmTenantId("54322")
        .accountStatus(Status.Active)
        .addProfile(new Profile()
            .type(ProfileType.IB)
            .name("John Smith")
            .country("United Kingdom")
            .contactEmail("john.smith@ib-partner.io")
            .accountCurrency("USD")
            .status(Status.Active)
            .address("10 Downing St, London")
            .dateOfBirth(LocalDate.of(1985, 6, 15))
            .kycStatus("Verified")
            .referredById("EXT-MIB-001")   // parent IB; omit for a top-level IB
            .externalId("EXT-IB-001")
            .referralLinkId(3L)
            .isDeleted(false)
            .onboardingStatus(OnboardingStatus.Completed)));

Request fields

crmAccountId
String
required
The user's id in your CRM (e.g. "CRM-USER-09876"). The upsert key for the CRM user.
accountStatus
Status
required
Active or Inactive.
profiles
List<Profile>
optional
Profiles to create or update for this user. Leave it empty to create the CRM user alone and add profiles later. See Profile fields.
crmTenantId
Integer
optional
Your brand's tenant id in your CRM. Only needed when one integration serves several brands: FYNXT resolves the brand from it, and it must belong to the tenant your API key is for.
actor(…)
id, name, email, role
optional
Overrides the client's default actor for this call only.

Profile types

TypeWhoReferred by
IBIntroducing Broker. Refers clients and earns rebates on their trading.Optional. Set referredById to a parent IB's externalId to build a multi-level IB tree.
AffiliateMarketing partner. Earns on referred client activity, usually CPA.Optional. Same as IB.
TraderEnd client who trades. Owns trading accounts.The IB or Affiliate who referred them, if any.

FYNXT rejects a referredById chain that loops back on itself.

Profile fields

type
ProfileType
required
IB, Affiliate or Trader.
externalId
String
required
Your CRM's id for this profile (e.g. "EXT-TRADER-006"). The upsert key for the profile, and the id other profiles and trading accounts use to refer to it.
name
String
required
Full name of the person or company.
contactEmail
String
required
Contact email for this profile.
status
Status
required
Active or Inactive.
accountCurrency
String
optional
3-letter ISO currency code (e.g. "EUR").
country
String
optional
Country of residence (e.g. "Canada").
address
String
optional
Postal address.
dateOfBirth
LocalDate
optional
Sent as YYYY-MM-DD.
kycStatus
String
optional
Your KYC state, e.g. "Verified" or "Pending".
referredById
String
optional
The externalId of the IB or Affiliate who referred this profile.
referralLinkId
Long
optional
The FYNXT referral link the client signed up through. You get it from the referral redirect (see Referral overview).
onboardingStatus
OnboardingStatus
optional
NotStarted, InProgress, Completed or Rejected.
isDeleted
Boolean
optional
Set true to soft-delete the profile.

Updating profiles

updateAccountProfiles(request) takes the same request and calls PUT /api/abstraction/crm/upsert-account-profiles. Use it when a profile changes in your CRM: name, email, status, KYC, onboarding stage or referrer.

On an update, referredById works in three ways:

You sendResult
not set (null)The current referrer is kept.
"" (empty string)The profile becomes top level, with no referrer.
an externalIdThe profile moves under that referrer.

The client never sends fields you left unset, so leaving referredById alone keeps the referrer.

Attaching trading accounts

attachTradingAccount(request) links a login on a trading server to a Trader profile and its referrer. Calls POST /api/abstraction/crm/crm/trading-accounts and returns the TradingAccount.

TradingAccount ta = fynxt.attachTradingAccount(
    new AttachTradingAccountRequest()
        .tradeServerId("tsv_01knh7qz80e61tv085bqwf06y1")
        .tradeLoginId("110001")
        .externalClientId("EXT-TRADER-006")
        .externalReferredById("EXT-IB-001")
        .currency("USD")
        .isDeleted(false)
        .status(TradingAccountStatus.Active));
Before you attach. FYNXT does not create the trading account here. It maps an account FYNXT already has from the trading-server sync. The call fails if:
  • no account with tradeLoginId exists on tradeServerId yet,
  • externalClientId is not a Trader profile,
  • externalReferredById is missing or is not an IB or Affiliate,
  • the account is already mapped to another client or another referrer. Moving an account between referrers is a client movement, done in the FYNXT back office.
Calling it again with the same client and referrer is safe and only refreshes the mapping.

Request fields

tradeServerId
String
required
External id of the trading server (e.g. "tsv_01knh7qz80e61tv085bqwf06y1").
tradeLoginId
String
required
The login on that server, e.g. an MT4/MT5 account number such as "110001".
externalClientId
String
required
The externalId of the Trader profile that owns the account.
externalReferredById
String
required
The externalId of the IB or Affiliate the account is attributed to.
currency
String
required
3-letter ISO currency code of the account (e.g. "USD").
status
TradingAccountStatus
required
Active, Inactive or Blocked.
referralLinkId
Long
optional
Ties the account to one referral link, which decides its program and any forced tier. The link must belong to externalReferredById, be Active, and promote a live rebate program.
isDeleted
Boolean
optional
Soft-delete flag. Defaults to false.
actor(…)
id, name, email, role
optional
Overrides the client's default actor for this call only.

Error handling

Every method throws FynxtApiException (unchecked) on a non-2xx response or a network failure. statusCode() is the HTTP status, or 0 when no response arrived. body() is the raw response body, which may be empty.

StatusMeaningWhat to do
400The request broke a rule: unknown profile or referrer, wrong profile type, trading account not found, referral link not usable, circular referrer chain.Fix the data. Do not retry unchanged.
401 / 403Missing or wrong API key, or a crmTenantId from another tenant.Check the key and tenant.
5xx / 0Server or network failure.Retry with backoff. Both calls are upserts, so retrying is safe.

In production the API does not return the reason for a 400. Log the request you sent along with the status.

Client source

Copy this file as is. Replace the package name with your own.

package com.yourbroker.fynxt;

import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;

import java.io.IOException;
import java.math.BigDecimal;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;

/** Client for the FYNXT Incentive CRM APIs: onboarding, settlement and event tracking. Thread-safe; build once and reuse. */
public final class FynxtCrmClient {

    private static final String UPSERT_PROFILES = "/api/abstraction/crm/upsert-account-profiles";
    private static final String TRADING_ACCOUNTS = "/api/abstraction/crm/crm/trading-accounts";
    private static final String REBATE_SETTLEMENT_UPDATE = "/api/abstraction/crm/api/settlements/settlement-update";
    private static final String CPA_SETTLEMENT_UPDATE = "/api/abstraction/crm/cpa-scheme/settlement/settlement-update";
    private static final String REWARD_SETTLEMENT_UPDATE = "/api/abstraction/crm/api/settlements/reward/settlement-update";
    private static final String SETTLE_REBATES = "/api/abstraction/crm/api/settlements/settle/";
    private static final String SETTLE_CPA = "/api/abstraction/crm/cpa-scheme/settlement/settle/";
    private static final String SETTLE_REWARDS = "/api/abstraction/crm/api/settlements/reward/settle/";
    private static final String TRACK_EVENT = "/api/referral/events/track";

    private final String baseUrl;
    private final String[] authHeaders;
    private final Actor defaultActor;
    private final Duration timeout;
    private final HttpClient http;
    private final ObjectMapper json = new ObjectMapper()
            .registerModule(new JavaTimeModule())
            .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
            .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
            .setSerializationInclusion(JsonInclude.Include.NON_NULL)
            .setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY);

    private FynxtCrmClient(Builder b) {
        this.baseUrl = Objects.requireNonNull(b.baseUrl, "baseUrl").replaceAll("/+$", "");
        this.authHeaders = Objects.requireNonNull(b.authHeaders, "tenantApiKey or legacyApplicationKey");
        this.defaultActor = b.actor;
        this.timeout = b.timeout;
        this.http = HttpClient.newBuilder().connectTimeout(b.timeout).build();
    }

    public static Builder builder() { return new Builder(); }

    /** Creates or updates a CRM user and the given profiles (POST). */
    public CrmAccount upsertAccountProfiles(UpsertAccountProfilesRequest request) {
        return send("POST", UPSERT_PROFILES, request, CrmAccount.class);
    }

    /** Edits an existing CRM user's profiles (PUT). A null referredById keeps the current referrer. */
    public CrmAccount updateAccountProfiles(UpsertAccountProfilesRequest request) {
        return send("PUT", UPSERT_PROFILES, request, CrmAccount.class);
    }

    /** Maps an existing trading-server login to a Trader profile and its IB or Affiliate. */
    public TradingAccount attachTradingAccount(AttachTradingAccountRequest request) {
        return send("POST", TRADING_ACCOUNTS, request, TradingAccount.class);
    }

    /** Records a referral conversion event (deposit, KYC, first trade, ...) from your backend. */
    public TrackEventResult trackEvent(TrackEventRequest request) {
        return send("POST", TRACK_EVENT, request, TrackEventResult.class);
    }

    /**
     * Settles a rebate program's unsettled rewards: one record per IB, published to Kafka and,
     * for legacy systems, to the settlement webhook. Returns the records created.
     */
    public SettlementRun settleRebates(long programId) {
        return send("POST", SETTLE_REBATES + programId, null, SettlementRun.class);
    }

    /** Settles a CPA program's unsettled rewards and publishes them to Kafka. Returns the records created. */
    public SettlementRun settleCpa(long programId) {
        return send("POST", SETTLE_CPA + programId, null, SettlementRun.class);
    }

    /** Settles a reward program's unsettled rewards and publishes them to Kafka. data() is a status message. */
    public SettlementResult settleRewards(long programId) {
        return send("POST", SETTLE_REWARDS + programId, null, SettlementResult.class);
    }

    /** Reports the payout outcome of rebate settlement records. */
    public SettlementResult updateRebateSettlements(SettlementUpdate update) {
        return send("POST", REBATE_SETTLEMENT_UPDATE, update, SettlementResult.class);
    }

    /** Reports the payout outcome of CPA settlement records. externalRefId must be the numeric record id. */
    public SettlementResult updateCpaSettlements(SettlementUpdate update) {
        return send("POST", CPA_SETTLEMENT_UPDATE, update, SettlementResult.class);
    }

    /** Reports the payout outcome of reward settlement records. */
    public SettlementResult updateRewardSettlements(RewardSettlementUpdate update) {
        return send("POST", REWARD_SETTLEMENT_UPDATE, update, SettlementResult.class);
    }

    private <T> T send(String method, String path, Object body, Class<T> type) {
        if (body instanceof AuditedRequest<?> r && r.actionDoneById == null && defaultActor != null) r.actor(defaultActor);
        try {
            HttpRequest request = HttpRequest.newBuilder(URI.create(baseUrl + path))
                    .timeout(timeout)
                    .header("Content-Type", "application/json")
                    .headers(authHeaders)
                    .method(method, body == null ? HttpRequest.BodyPublishers.noBody()
                            : HttpRequest.BodyPublishers.ofString(json.writeValueAsString(body)))
                    .build();
            HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
            if (response.statusCode() / 100 != 2) {
                throw new FynxtApiException(response.statusCode(), response.body(), null);
            }
            return response.body().isBlank() ? null : json.readValue(response.body(), type);
        } catch (IOException | InterruptedException e) {
            if (e instanceof InterruptedException) Thread.currentThread().interrupt();
            throw new FynxtApiException(0, null, e);
        }
    }

    public static final class Builder {
        private String baseUrl;
        private String[] authHeaders;
        private Actor actor;
        private Duration timeout = Duration.ofSeconds(30);

        public Builder baseUrl(String v) { baseUrl = v; return this; }
        /** Your tenant's CRM API key, sent as X-Tenant-Api-Key. */
        public Builder tenantApiKey(String key) { authHeaders = new String[] { "X-Tenant-Api-Key", key }; return this; }
        /** Legacy shared application key: X-API-KEY plus X-Tenant-Slug. */
        public Builder legacyApplicationKey(String key, String tenantSlug) {
            authHeaders = new String[] { "X-API-KEY", key, "X-Tenant-Slug", tenantSlug };
            return this;
        }
        /** Default actor for requests that do not set their own. */
        public Builder actor(String id, String name, String email, String role) { actor = new Actor(id, name, email, role); return this; }
        public Builder timeout(Duration v) { timeout = v; return this; }
        public FynxtCrmClient build() { return new FynxtCrmClient(this); }
    }

    // --- Requests -------------------------------------------------------------

    public record Actor(String id, String name, String email, String role) {}

    /** Who made the change, recorded in the FYNXT audit trail. */
    abstract static class AuditedRequest<T extends AuditedRequest<T>> {
        private String actionDoneById, actionDoneByName, actionDoneByEmail, actionDoneByRole;

        public T actor(String id, String name, String email, String role) { return actor(new Actor(id, name, email, role)); }

        @SuppressWarnings("unchecked")
        T actor(Actor a) {
            actionDoneById = a.id(); actionDoneByName = a.name(); actionDoneByEmail = a.email(); actionDoneByRole = a.role();
            return (T) this;
        }
    }

    public static final class UpsertAccountProfilesRequest extends AuditedRequest<UpsertAccountProfilesRequest> {
        private String crmAccountId;
        private String crmTenantId;
        private Status accountStatus;
        private final List<Profile> profiles = new ArrayList<>();

        public UpsertAccountProfilesRequest crmAccountId(String v) { crmAccountId = v; return this; }
        /** The FYNXT tenant id assigned to your brokerage. */
        public UpsertAccountProfilesRequest accountStatus(Status v) { accountStatus = v; return this; }
        public UpsertAccountProfilesRequest crmTenantId(Integer v) { crmTenantId = v; return this; }
        public UpsertAccountProfilesRequest addProfile(Profile p) { profiles.add(p); return this; }
    }

    public static final class Profile {
        private ProfileType type;
        private String externalId, name, contactEmail, accountCurrency, country, address, kycStatus, referredById;
        private Status status;
        private LocalDate dateOfBirth;
        private Long referralLinkId;
        private OnboardingStatus onboardingStatus;
        private Boolean isDeleted;

        public Profile type(ProfileType v) { type = v; return this; }
        public Profile externalId(String v) { externalId = v; return this; }
        public Profile name(String v) { name = v; return this; }
        public Profile contactEmail(String v) { contactEmail = v; return this; }
        public Profile status(Status v) { status = v; return this; }
        public Profile accountCurrency(String v) { accountCurrency = v; return this; }
        public Profile country(String v) { country = v; return this; }
        public Profile address(String v) { address = v; return this; }
        public Profile dateOfBirth(LocalDate v) { dateOfBirth = v; return this; }
        public Profile kycStatus(String v) { kycStatus = v; return this; }
        /** On an update: null keeps the referrer, "" makes the profile top level. */
        public Profile referredById(String v) { referredById = v; return this; }
        public Profile referralLinkId(Long v) { referralLinkId = v; return this; }
        public Profile onboardingStatus(OnboardingStatus v) { onboardingStatus = v; return this; }
        public Profile isDeleted(Boolean v) { isDeleted = v; return this; }
    }

    public static final class AttachTradingAccountRequest extends AuditedRequest<AttachTradingAccountRequest> {
        private String tradeServerId, tradeLoginId, externalClientId, externalReferredById, currency;
        private TradingAccountStatus status;
        private Long referralLinkId;
        private Boolean isDeleted;

        public AttachTradingAccountRequest tradeServerId(String v) { tradeServerId = v; return this; }
        public AttachTradingAccountRequest tradeLoginId(String v) { tradeLoginId = v; return this; }
        public AttachTradingAccountRequest externalClientId(String v) { externalClientId = v; return this; }
        public AttachTradingAccountRequest externalReferredById(String v) { externalReferredById = v; return this; }
        public AttachTradingAccountRequest currency(String v) { currency = v; return this; }
        public AttachTradingAccountRequest status(TradingAccountStatus v) { status = v; return this; }
        public AttachTradingAccountRequest referralLinkId(Long v) { referralLinkId = v; return this; }
        public AttachTradingAccountRequest isDeleted(Boolean v) { isDeleted = v; return this; }
    }

    /** Rebate or CPA payout outcomes. batchReferenceId is your CRM's batch id, if it batches. */
    public record SettlementUpdate(String batchReferenceId, List<Payout> payouts) {}

    /** externalRefId: the settlementRecordId (or, for rebate only, the settlementEventId) FYNXT sent. */
    public record Payout(String externalRefId, String crmReferenceId, PayoutStatus status,
                         String failureReason, Instant processedAt) {
        public static Payout success(String externalRefId, String crmReferenceId) {
            return new Payout(externalRefId, crmReferenceId, PayoutStatus.SUCCESS, null, Instant.now());
        }
        public static Payout failed(String externalRefId, String crmReferenceId, String failureReason) {
            return new Payout(externalRefId, crmReferenceId, PayoutStatus.FAILED, failureReason, Instant.now());
        }
    }

    public record RewardSettlementUpdate(String batchReferenceId, List<RewardPayout> payouts) {}

    /** Matched by settlementRecordId; settlementEventId is used only when settlementRecordId is null. */
    public record RewardPayout(Long settlementRecordId, String settlementEventId, String crmReferenceId,
                               PayoutStatus status, String failureReason, Instant processedAt) {
        public static RewardPayout success(Long settlementRecordId, String crmReferenceId) {
            return new RewardPayout(settlementRecordId, null, crmReferenceId, PayoutStatus.SUCCESS, null, Instant.now());
        }
        public static RewardPayout failed(Long settlementRecordId, String crmReferenceId, String failureReason) {
            return new RewardPayout(settlementRecordId, null, crmReferenceId, PayoutStatus.FAILED, failureReason, Instant.now());
        }
    }

    public static final class TrackEventRequest {
        private EventType event;
        private Long brokerId, clientId;
        private String clickId, currency, metadata;
        private BigDecimal amount;
        private final Map<String, Object> data = new LinkedHashMap<>();

        public TrackEventRequest event(EventType v) { event = v; return this; }
        /** Your FYNXT tenant id. Must match the tenant of your API key. */
        public TrackEventRequest brokerId(Long v) { brokerId = v; return this; }
        /** The click-id from the referral link the client arrived through. */
        public TrackEventRequest clickId(String v) { clickId = v; return this; }
        /** FYNXT's internal id of the client's Trader profile. Takes precedence over email. */
        public TrackEventRequest clientId(Long v) { clientId = v; return this; }
        /** Finds the Trader profile with this contact email, or creates one. */
        public TrackEventRequest email(String v) { data.put("email", v); return this; }
        /** Name for a Trader profile created from email. */
        public TrackEventRequest name(String v) { data.put("name", v); return this; }
        public TrackEventRequest amount(BigDecimal v) { amount = v; return this; }
        public TrackEventRequest currency(String v) { currency = v; return this; }
        /** Free-form JSON object, stored with the event as is. */
        public TrackEventRequest metadata(String json) { metadata = json; return this; }
    }

    // Constant names are the exact values the API accepts.
    public enum EventType {
        REFERRAL_INITIATED, EMAIL_SIGNUP, ACCOUNT_CREATION, REGISTRATION, VERIFICATION, LOGIN,
        FIRST_DEPOSIT, RECURRING_DEPOSIT, FIRST_TRADE, RECURRING_TRADE, KYC_COMPLETED, WITHDRAWAL,
        ACCOUNT_UPGRADE, SUBSCRIPTION, CUSTOM, CONTENT_DOWNLOAD, WEBINAR_REGISTRATION, NEWSLETTER_SUBSCRIPTION
    }
    public enum PayoutStatus { SUCCESS, FAILED }
    public enum ProfileType { IB, Affiliate, Trader }
    public enum Status { Active, Inactive }
    public enum OnboardingStatus { NotStarted, InProgress, Completed, Rejected }
    public enum TradingAccountStatus { Active, Inactive, Blocked }

    // --- Responses ------------------------------------------------------------

    public record CrmAccount(Long id, String crmAccountId, String status, String country,
                             String timezone, String language, String registrationDate) {}

    public record TradingAccount(Long id, Long profileId, String tradeServerId, String platformLogin,
                                 String currency, String type, String status, String name) {}

    /** FYNXT's response envelope. Unknown or already-decided records are skipped, not errors. */
    public record SettlementResult(Object data, ResultStatus status) {}

    public record TrackEventResult(TrackedEvent data, ResultStatus status) {}

    /** duplicate: this client (or click) already had an event of this type, so it does not count again. */
    public record TrackedEvent(Long id, String eventType, Long clientId, String timestamp, BigDecimal amount,
                               String currency, boolean conversion, boolean duplicate, Long clickId) {}

    /** Records created by a rebate or CPA settle call; empty when nothing was unsettled. */
    public record SettlementRun(List<SettlementRecord> data, ResultStatus status) {}

    /** settlementId is the record id to echo back as externalRefId in a settlement update. */
    public record SettlementRecord(String settlementId, String settlementRunId, String crmRefer, String settlementStatus,
                                   String ibId, String tierId, String amount, String currency) {}

    public record ResultStatus(int code, String message) {}

    public static final class FynxtApiException extends RuntimeException {
        private final int statusCode;
        private final String body;

        FynxtApiException(int statusCode, String body, Throwable cause) {
            super(statusCode == 0 ? "FYNXT request failed" : "FYNXT API returned HTTP " + statusCode, cause);
            this.statusCode = statusCode;
            this.body = body;
        }

        /** HTTP status, or 0 when no response arrived. */
        public int statusCode() { return statusCode; }
        public String body() { return body; }
    }
}