Split Rules
Distribute incoming payment funds across multiple payees automatically with a persistent split rule, fed per payment by orderData.payeeDistribution when the rule is in weight mode.
A payment is split by the seller's active split rule: a persistent, versioned rule
that owns the fee policy and decides where each payee's share comes from, applied
automatically when the payment settles. The rule's allocationMode names the source:
absolute— the rule's own lines. Every payment splits the same way, and the payment'sorderData.payeeDistributionis ignored.weight(the default) — the payment'sorderData.payeeDistribution: the per-payment input that says how this payment divides between payees. The rule adds its fee policy on top.
Important
Both are supported, and neither is legacy or deprecated. This is the design, not a migration in progress.
payeeDistributionis not a standalone split mechanism: it is the per-payment input to aweight-mode rule. Anabsolute-mode rule ignores it, and with no active rule nothing splits at all. There is therefore no migration path from one to the other to publish, and none is coming.
Use an absolute-mode rule alone when the distribution is the same for every payment.
Use a weight-mode rule plus payeeDistribution when one fee policy applies across many
payments but each payment divides differently. Either way, the rule version that ran is
captured in the split decision and remains the explanation of record even after the rule
is replaced or retired.
Note
Two surfaces, one lifecycle. With a seller API key, manage split rules over REST under
/payments-api/v1/split-rules: the operations are listed under this tag in the API reference, with their request and response schemas, and are described in the OpenAPI document a client is generated from. With a dashboard session, manage them over GraphQL onPOST /dashboard/graphql(dashboard_sessioncookie required — see GraphQL Authentication), which is what the Fynex dashboard uses. Both surfaces enforce the same create → preview → activate contract described below, and the REST shape is set out under REST operations (API key).
Important
A split rule is not created active, and
statuscannot be set. Every rule is created inactive, previewed against a real amount, and then activated with the receipt the preview returns. Sendingstatus: "active"tocreateSplitRuleorupdateSplitRuleis rejected:
split rule status is lifecycle-controlled; create an inactive rule, preview it, then activate it
The splits section of the Fynex dashboard implements exactly this sequence and is the reference implementation.
The lifecycle
Three steps, in order. Skipping the preview is not possible: activation requires a receipt that only a successful preview issues.
Create it inactive.
createSplitRulestores the rule and its lines. Omitstatus— the field is deprecated and any value other thaninactiveis rejected. Nothing runs against payments yet.Preview the saved rule.
previewSplitwithruleIdset runs the rule against an amount and a distribution you supply, and returns the exact allocations it would produce — plus anactivationReceiptvalid for 10 minutes. PassruleIdor an inlinerule, never both; only theruleIdform issues a receipt. If the rule cannot run,wouldFailReasonsays why and no receipt is issued.Activate with the receipt.
activateSplitRuletakes the ruleid, thepreviewReceiptandconfirmed: true. The receipt is bound to that rule, that seller, the session that previewed it, and a fingerprint of the exact rule snapshot that was previewed — so editing the rule between preview and activation invalidates it:split rule changed after preview; run preview again
Another rule's receipt, another seller's receipt or an expired one is refused the same way, and confirmed: false answers activation confirmation is required. Activating a rule that is already active answers split rule is already active.
activateSplitRule returns SplitRuleActivationResult, whose deactivated array names every rule the activation stood down.
Three rules the API enforces
- One active rule per seller. Activation is an atomic swap, not an addition. Every other active rule becomes inactive in the same transaction, including one that was activated with a later
effectiveFromand has not started yet. That is whatdeactivatedreports. A unique index in the database backs the same invariant. - Active rules are immutable.
updateSplitRule,upsertSplitRuleLineByPayeeanddeleteSplitRuleLineByPayeeall refuse an active rule: "active split rules are immutable; clone the rule, edit the inactive copy, preview it, then activate it". UsecloneSplitRuleto get an editable inactive copy. - Every payee a rule pays must be payable. Creating or activating a rule whose enabled line names a disabled or deleted payee is refused (
422on activation). The live split would otherwise refuse that payee after the payment. - Active rules cannot be deleted.
deleteSplitRulerefuses while the rule is active. Deactivate it first, or activate its replacement — which deactivates it for you.
Note
scheduleSplitRuleis disabled and returns an error. To schedule, seteffectiveFrom/effectiveToon the inactive rule, preview the saved version, then activate it. The swap is still immediate: the rule that was active stops now, not at the new rule'seffectiveFrom. Until that date no rule applies, and payments that settle in the gap stay unsplit on the main wallet.effectiveTomust be later thaneffectiveFrom. Activating a rule whoseeffectiveTohas already passed (for example, one you deactivated earlier) clears that end: the rule applies from the moment it is activated.
REST operations (API key)
Everything above — the lifecycle, the three enforced rules, the receipt/fingerprint contract — applies identically here. The only difference from GraphQL is the wire shape and the identity a receipt is bound to: a REST receipt is bound to the seller account behind your API key (not a dashboard user/session), so it authorizes activation for any request authenticated with that same key.
All endpoints below require the standard seller Authorization: Bearer <api-key> header and live under
/payments-api/v1. Each one is in the OpenAPI document (/payments-api/v1/openapi.json) with its request
and response schemas and the statuses it answers, and is listed under this tag in the API reference — a
generated client can run create → preview → activate with no hand-written calls.
| Operation | Endpoint | Notes |
|---|---|---|
| Create | POST /split-rules |
Body per SplitRuleCreateRequest. Always created inactive. |
| List | GET /split-rules |
Paginated (limit, offset), newest first. |
| Get one | GET /split-rules/{id} |
|
| Get active | GET /split-rules/active |
The rule in effect now; 404 when there is none. |
| Clone | POST /split-rules/{id}/clone |
Returns a new inactive draft with the same lines. |
| Delete | DELETE /split-rules/{id} |
Refused while the rule is active. |
| Deactivate | POST /split-rules/{id}/deactivate |
Stops the rule now; nothing splits until another rule is activated. |
| Preview (issues receipt) | POST /split-rules/{id}/preview-activation |
Rule must be inactive. Returns activationReceipt + activationReceiptExpiresAt (10 minutes). |
| Activate | POST /split-rules/{id}/activate |
Body: { "previewReceipt": "<from preview>", "confirmed": true }. |
| Calculate (dry run) | POST /split-rules/calculate |
Simulates a hypothetical payment against the active rule (or an inline rule); does not touch activation state. Every amount is integer minor units, distribution[].amount included. |
Errors are the usual {"error": "<message>", "code": "<code>"} JSON: a 401 for a missing or invalid
key (missing_api_key, invalid_api_key), a 403 for an inactive seller account
(account_not_active) or — on the six operations that change rules — a delegated dashboard session
whose user may not change split rules (insufficient_scope; a seller API key always may), and a 404
not_found for an {id} that is not digits, which matches no route.
PATCH /split-rules/active, PUT /split-rules/active/lines, PUT and DELETE
/split-rules/active/lines/by-payee/{payeeRef}, and POST /split-rules/{id}/schedule are retired on REST
the same way as on GraphQL (immutable-active / disabled-scheduling): each answers 409. Clone, edit the
inactive copy, preview, activate. They are deliberately absent from the OpenAPI document, so that no
generated client offers them.
Worked example — create, preview, activate
BASE="https://api.fynex.ai/payments-api/v1"
AUTH="Authorization: Bearer $SELLER_API_KEY"
# 1. Create — inactive by default.
RULE=$(curl -s -X POST "$BASE/split-rules" -H "$AUTH" -H "Content-Type: application/json" -d '{
"type": "custom",
"lines": [{ "payeeRef": { "id": 101 }, "allocationType": "percent_bps", "percentBps": 5000 }]
}')
ID=$(echo "$RULE" | jq -r '.id')
# 2. Preview — issues the activation receipt. No amount/distribution needed; this previews the
# saved rule content itself, unlike /split-rules/calculate which simulates a hypothetical payment.
PREVIEW=$(curl -s -X POST "$BASE/split-rules/$ID/preview-activation" -H "$AUTH")
RECEIPT=$(echo "$PREVIEW" | jq -r '.activationReceipt')
# 3. Activate with the receipt (valid 10 minutes).
curl -s -X POST "$BASE/split-rules/$ID/activate" -H "$AUTH" -H "Content-Type: application/json" -d "{
\"previewReceipt\": \"$RECEIPT\",
\"confirmed\": true
}"
A refused receipt answers 409. A stale preview — the rule's content or version changed since, for
instance through the GraphQL updateSplitRule mutation if dashboard staff also adjust your rules —
answers split rule changed after preview; run preview again: preview again. A receipt that is missing,
malformed, expired, or was issued for another rule or seller answers the same
invalid or expired split rule activation receipt in every case, so a caller can't use the failure
shape to probe rule ids it doesn't own.
GraphQL operations
Queries
| Operation | Signature | Permission |
|---|---|---|
| List rules | splitRules(limit: Int, offset: Int, merchantId: ID): [SplitRule!]! |
SPLITRULES_READ |
| Get rule | splitRule(id: Int!): SplitRule |
SPLITRULES_READ |
| Get the active rule | activeSplitRule(merchantId: ID!): SplitRule |
SPLITRULES_READ |
| Preview a split | previewSplit(input: SplitRulePreviewInput!): SplitRulePreviewResult! |
SPLITRULES_READ |
| List executions | splitExecutions(dateFrom: Time, dateTo: Time, merchantId: ID): [SplitExecution!]! |
SPLITRULES_READ |
Mutations
| Operation | Signature | Permission |
|---|---|---|
| Create (inactive) | createSplitRule(input: CreateSplitRuleInput!): SplitRule! |
SPLITRULES_CREATE |
| Update (inactive only) | updateSplitRule(id: Int!, merchantId: ID, input: UpdateSplitRuleInput!): SplitRule! |
SPLITRULES_UPDATE |
| Activate | activateSplitRule(id: Int, input: ActivateSplitRuleInput): SplitRuleActivationResult! |
SPLITRULES_UPDATE |
| Deactivate | deactivateSplitRule(id: Int!, merchantId: ID): SplitRule! |
SPLITRULES_UPDATE |
| Clone (to edit an active rule) | cloneSplitRule(id: Int!, merchantId: ID): SplitRule! |
SPLITRULES_CREATE |
| Upsert one line by payee | upsertSplitRuleLineByPayee(input: UpsertSplitRuleLineByPayeeInput!): SplitRule! |
SPLITRULES_UPDATE |
| Delete one line by payee | deleteSplitRuleLineByPayee(input: DeleteSplitRuleLineByPayeeInput!): SplitRule! |
SPLITRULES_UPDATE |
| Delete (inactive only) | deleteSplitRule(id: Int!, merchantId: ID): Boolean! |
SPLITRULES_DELETE |
How Fynex splits money
This section is the one description of what a split does with money. Every other page that mentions splits points here. All the figures below are integer minor units (10000 is €100.00) and were produced by the same calculation the preview, POST /split-rules/calculate and the live split run.
What a split divides, and when
A split runs once, when the payment's money is credited to the seller's main wallet at settlement. It divides the settled credit (amountBase: net_settled, the only base supported today; gross_payment is refused). It then moves each payee's net from the main wallet to that payee's wallet as a WalletTransfer, and records a SplitExecution with the ruleVersion it ran and a decision record (GET /payments/{paymentId}/split-decisions). The rule in effect at the credit's settlement time is the one that applies.
Weight mode: the payment says how it divides
Each payee gets amount × weight / sum of weights, where the weights are the payment's orderData.payeeDistribution shares. The shares always add up to the whole amount.
Example. A
weightrule oftype: fixedwith a 5 % fee (percentBps: 500), and a payment of 10000 distributed 7500 / 2500:
Payee Share Fee (5 %) Net A 7500 375 7125 B 2500 125 2375 The seller keeps the fees, 500, on its own operational wallet. Nothing else is left over.
Three equal weights on 100 give 34 / 33 / 33: the unit that does not divide evenly goes to the largest dropped fraction, ties to the lowest payee ID (see Rounding).
Absolute mode: the rule says how it divides
Each enabled line claims allocationPercentBps of the amount, allocationFixedAmountMinor, or both. Whatever the lines do not claim is the remainder (see Where the rest goes).
Percentage. Lines of 60 % and 30 % on 10001 pay 6001 and 3000. The 90 % the two lines take together is 9001 (half-up), and the extra unit goes to the 60 % line, which dropped the larger fraction. The remainder is 1000.
Fixed. Lines of 2000 and 500 on 10000 pay exactly 2000 and 500. The remainder is 7500.
Mixed. A line of 10 % plus 200 on 10000 pays 1000 + 200 = 1200. The remainder is 8800.
Fees
The fee is withheld from each payee's share and goes to the seller's own operational wallet:
type: fixed: the rule'spercentBpsandfixedAmountMinorapply to every payee's share alike.type: custom: each line carries its ownpercentBpsandfixedAmountMinor. Every payee that gets a share must have an enabled line.
The fee is share × percentBps / 10000, rounded half-up, plus the fixed part. A fee larger than the share fails the split.
Example. A
customline of 60 % with a fee of 2 % + 30 on 10000: share 6000, fee 120 + 30 = 150, net 5850.
Priority and over-allocation
When the lines of an absolute rule claim more than the amount, overAllocationPolicy decides. priority (lower goes first, ties to the lowest payee ID) matters only to cap_by_priority.
Two fixed lines of 7000 (priority 1) and 5000 (priority 2) on 10000, i.e. 12000 claimed:
Policy Result fail(the default)The split fails and moves nothing; the money stays on the main wallet. cap_by_priority7000 and 3000: priority 1 is paid in full, priority 2 gets what is left. scale_down_all5833 and 4167: both scaled to fit, the leftover unit by largest remainder.
scale_down_percentkeeps fixed claims whole and scales only the percentage claims. A fixed 2000 plus two 50 % lines on 10000 pays 2000, 4000 and 4000. If the fixed claims alone exceed the amount, the split fails.
A rule that fails at preview never gets an activation receipt. A rule can still preview cleanly on a round amount and fail on a real one, which is why fail is worth choosing deliberately.
Minimum and maximum
In absolute mode, a line's minAmountMinor raises its share and maxAmountMinor caps it. Both apply after the over-allocation policy.
Example. A 1 % line with
minAmountMinor: 500and a 50 % line withmaxAmountMinor: 3000, on 10000, pay 500 (1 % would be 100) and 3000 (50 % would be 5000). The remainder is 6500.
If raising shares to their minimums would take more than the amount, the split fails.
Rounding
All split arithmetic is in integer minor units, and the preview, /split-rules/calculate and the live execution run the same code, so they always round the same way.
- Absolute-mode percent lines. Together they receive
gross × (sum of their allocationPercentBps) / 10000, rounded half-up. Each line first gets its share with the fraction dropped. Any minor units left over go one each to the lines with the largest dropped fractions. Ties go to the lowest payee ID. A 50 % / 50 % rule on 101 pays 51 to the lower payee ID and 50 to the other, never 51 + 51. A single percent line simply rounds half-up. - Fixed amounts (
allocationFixedAmountMinor) are added to a line exactly. They are never rounded. - Weight mode. Each payee gets
gross × weight / sum of weightswith the fraction dropped. The minor units left over go the same way: largest dropped fraction first, ties to the lowest payee ID. The shares always sum to exactly the gross. - Fees. Each line's fee is
share × percentBps / 10000, rounded half-up, plus its fixed fee.
A live split can pay less than the preview showed in one case only; see When a wallet cannot cover it.
Whatever the absolute-mode lines leave unallocated is the remainder, and remainderPolicy decides where it goes. See Where the rest goes.
Where the rest goes
A split never leaves money unaccounted for. Whatever the payees' nets do not take stays with the seller, and previewSplit names it instead of leaving you to subtract:
remainderMinor— the part of the amount that no allocation takes;remainderDestination— where that remainder goes:main_walletorremainder_wallet;sellerRetainedMinor— the seller's own share:remainderMinorplustotalFeeMinor.
The payees' netAmountMinor values plus sellerRetainedMinor always add up to totalAmountMinor. The fees always go to the seller's own operational wallet. The remainder goes as follows:
| Case | remainderMinor |
remainderDestination |
|---|---|---|
| Weight mode | 0: the weights share out the whole amount, so the seller keeps only the fees |
main_wallet |
Weight mode, every weight 0 |
The whole amount: the split is skipped | main_wallet |
Absolute mode, remainderPolicy: to_main_wallet (the default) |
What the lines leave unallocated | main_wallet: it stays where the payment was credited |
Absolute mode, remainderPolicy: to_remainder_wallet |
What the lines leave unallocated | remainder_wallet: moved to the rule's remainderWalletId |
The rule would fail (wouldFailReason is set) |
The whole amount: a failed split moves nothing | main_wallet |
| No active rule | The whole amount: the split is skipped | Not reported. previewSplit returns an error unless you pass an inline rule |
Weight mode ignores remainderPolicy, so it reports main_wallet whatever the policy says. A zero remainder moves nothing, whichever destination is reported.
A 30 % / 50 % absolute rule on 10000, with a 1 % fee on the 30 % line, allocates 3000 (fee 30, net 2970) and 5000 (net 5000). It reports remainderMinor: 2000, totalFeeMinor: 30 and sellerRetainedMinor: 2030. 2970 + 5000 + 2030 = 10000.
When a wallet cannot cover it
- At split time. A payment credited net of Fynex's fees is split on its gross. When the seller's main wallet cannot cover those fees, the payees (and a remainder routed to the remainder wallet) give back up to that amount, pro rata to their nets. Finance gets a
split_fees_uncoveredreview. This is the one case where a live split pays less than the preview showed. - After a refund or chargeback. A payee who has already been paid out may not hold their share of it. See Refunds and chargebacks: the shortfall is shown and the seller's main wallet bears it. Fynex never drives a payee wallet negative.
- Paying out. A payout is capped at what the wallet can withdraw: its available balance less any unmatured hold.
Holds
Every leg a split posts lands in the destination wallet at once, but under a maturity hold. The part the main wallet keeps is held the same way. It becomes withdrawable T+N business days after settlement, where N is the seller's payoutHoldDays (3 by default, between 1 and 30), counted on the currency's settlement calendar. Until then it counts in the wallet's balance but not in what a payout can take.
On a Demo account there is no hold: sandbox money has no chargeback window to wait out, so the payment's credit and every leg of its split are withdrawable as soon as they post, and a payee can be paid in the same session as the payment.
Rule versions
A rule never changes under a payment:
- Every activation records a new
version, and eachSplitExecutionstores theruleVersionit ran. - The decision record keeps the rule and its lines exactly as they were, so a split stays explainable after the rule is replaced.
- An active rule cannot be edited, only replaced: clone it, edit the copy, preview it, activate it.
- One rule is active per seller at a time (see Three rules the API enforces).
Refunds and chargebacks
A split runs once. Money that later goes back to the buyer is not taken back from the payees automatically. What Fynex does instead:
Who bears it. Every party gives back the same fraction of what the split paid it as the fraction of the payment that went back. That covers each payee, the rule's commission, a remainder routed to its own wallet, and the seller's main wallet for what it kept. A refund of 30 % of the payment takes back 30 % of each leg. The part of the payment the split never divided (for example fees withheld before a net_settled or annex-net split) is the seller's to bear. Rounding is half-up on the cumulative refunded amount, spread over the legs by largest remainder, so a series of partial refunds adds up to exactly the whole split once the payment is fully refunded.
What happens. For every refund and every lost chargeback on a live payment whose split succeeded, Fynex computes that plan once. It records the plan with each wallet's available balance and any shortfall, and sends it to Fynex finance for review. No wallet is debited automatically: an operator makes the transfers. The payee wallets, the commission wallet and the remainder wallet each return their share to the seller's main wallet, and the main wallet carries the refund. This runs when payments.split.refund_reversal_review is on, and picks up refunds and chargebacks from the last 14 days.
| Scenario | What Fynex does | Who bears it | API / webhook |
|---|---|---|---|
| Refund before the split (the credit has not arrived yet) | When the split runs, the refund is planned as refund_before_split. Some providers net the refund out of the credit the split divides, so the operator checks the credit before acting on the plan. |
As above, only if the credit included the refunded money. | PaymentRefunded webhook when the refund succeeds. |
| Refund after the split, before any payout | Planned as refund_after_split. The legs hold the money, so there is no shortfall. |
Each leg pro rata; the seller bears the undivided part. | PaymentRefunded webhook. The split execution and its decision record (GET /payments/{paymentId}/split-decisions) are unchanged. |
| Refund after a payee was paid out | Same plan. A leg whose available balance is below its give-back shows a shortfall. | Until the payee is asked for it outside Fynex, the seller's main wallet bears the shortfall. Fynex never drives a payee wallet negative. | PaymentRefunded webhook. |
| Partial refund | Each partial refund gets its own plan, computed on the cumulative refunded amount. | Pro rata to the refunded fraction. | PaymentRefunded webhook per refund. |
| Insufficient payee balance | Shown as shortfallMinor per leg in the plan. |
Seller's main wallet, as above. | — |
| Chargeback opened / under review | Nothing moves. The dispute is recorded; the provider may hold the funds on its side. | — | See Disputes & chargebacks. |
| Chargeback won | Nothing moves (nothing moved when it opened). | — | — |
| Chargeback lost | Planned as chargeback_lost for the disputed amount, exactly like a refund of that amount. No chargeback fee is charged to the split today. |
Each leg pro rata; seller bears the undivided part. | — |
| Automatic vs manual reversal | Manual: Fynex decides and records who owes what, and finance moves the money. Automatic reversal of split legs is not available yet. | — | — |
A payment whose split moved nothing off the seller's main wallet (no payee legs, no commission) is recorded as not_applicable: the refund is the seller's alone. So is a refund or chargeback that arrives after earlier ones already took back everything the split divided. Neither asks finance for anything.
Example. A payment of 10000 settles as 9700 and splits 70 / 30: 6790 and 2910. A refund of 3000 (30 % of the payment) reverses 30 % of what the split divided, which is 2910: A gives back 2037 and B 873. The other 90 is the seller's, the fees the split never divided.
Type reference
SplitRule
| Field | Type | Description |
|---|---|---|
id |
Int! |
Rule ID |
merchantId |
ID! |
Owning seller account |
type |
SplitRuleType! |
fixed or custom |
status |
SplitRuleStatus! |
active or inactive. Read-only — set by the activation lifecycle, never by input |
amountBase |
SplitRuleAmountBase! |
net_settled, the settled credit the lines run against. gross_payment is refused today. |
allocationMode |
SplitRuleAllocationMode! |
weight or absolute — whether line allocations are relative shares or fixed claims |
remainderPolicy |
SplitRuleRemainderPolicy! |
Where leftover funds go: to_main_wallet or to_remainder_wallet |
overAllocationPolicy |
SplitRuleOverAllocationPolicy! |
How to handle over-allocation: cap_by_priority, scale_down_percent, scale_down_all, or fail |
remainderWalletId |
Int |
Target wallet for remainder (when remainderPolicy is to_remainder_wallet). It must be one of your own active wallets other than the main wallet. This is checked when the rule is saved and again when it is activated (422 if the wallet no longer qualifies) |
percentBps |
Int |
Fee in basis points (1 bps = 0.01 %) withheld from every payee's share when type is fixed |
fixedAmountMinor |
Int |
Fixed fee in minor units withheld from every payee's share when type is fixed |
effectiveFrom |
Time |
Rule starts applying from this time |
effectiveTo |
Time |
Rule stops applying after this time |
version |
Int! |
Incremented on each saved edit; SplitExecution.ruleVersion records which version ran |
lines |
[SplitRuleLine!]! |
Per-payee allocation lines |
SplitRuleLine
| Field | Type | Description |
|---|---|---|
id |
Int! |
Line ID |
splitRuleId |
Int! |
Parent rule |
payeeId |
Int! |
Recipient payee |
allocationType |
SplitRuleAllocationType! |
percent_bps, fixed_minor, or mixed |
percentBps |
Int |
This line's fee in basis points when the rule's type is custom (e.g. 200 = 2 %) — not its share |
fixedAmountMinor |
Int |
This line's fixed fee in minor units when the rule's type is custom |
allocationPercentBps |
Int |
The line's share in basis points, in absolute mode |
allocationFixedAmountMinor |
Int |
The line's fixed share in minor units, in absolute mode (added to allocationPercentBps when both are set) |
priority |
Int! |
Lower is paid first under overAllocationPolicy: cap_by_priority; ignored otherwise |
isEnabled |
Boolean! |
Whether this line is active |
minAmountMinor |
Int |
Raises the line's share to at least this, in absolute mode, after over-allocation |
maxAmountMinor |
Int |
Caps the line's share at this, in absolute mode, after over-allocation |
SplitRulePreviewResult
| Field | Type | Description |
|---|---|---|
ruleId |
Int |
The previewed rule, when previewing a saved one |
ruleVersion |
Int! |
Version the preview ran against. Activation checks it, and the rule fingerprint, against the live rule |
totalAmountMinor |
Int! |
Amount the preview distributed |
totalFeeMinor |
Int! |
Total fee across allocations |
allocations |
[SplitRulePreviewAllocation!]! |
Per-payee grossShareMinor, feeMinor, netAmountMinor |
remainderMinor |
Int! |
The part of totalAmountMinor no allocation takes: totalAmountMinor minus the grossShareMinor values. 0 in weight mode unless every weight is 0; the whole amount when wouldFailReason is set. See Where the rest goes |
remainderDestination |
SplitRuleRemainderDestination! |
Where remainderMinor goes: main_wallet or remainder_wallet. Only an absolute-mode rule with remainderPolicy: to_remainder_wallet reports remainder_wallet |
sellerRetainedMinor |
Int! |
The seller's own share: remainderMinor plus totalFeeMinor. The netAmountMinor values plus this always equal totalAmountMinor |
wouldFailReason |
String |
Non-null when the rule could not run; no receipt is issued |
activationReceipt |
String |
Opaque receipt for activateSplitRule. Only issued for a ruleId preview |
activationReceiptExpiresAt |
Time |
10 minutes after the preview |
SplitExecution
| Field | Type | Description |
|---|---|---|
id |
Int! |
Execution ID |
genericPaymentId |
Int |
Payment that triggered the split |
splitRuleId |
Int |
Rule that ran |
sourceWalletId |
Int! |
Wallet funds were distributed from |
amountMinor |
Int! |
Total amount distributed |
currencyCode |
CurrencyCode! |
Currency |
status |
String! |
Execution status |
ruleVersion |
Int! |
Snapshot of rule version at execution time |
requestedAt |
Time |
When the split was triggered |
postedAt |
Time |
When transfers were posted |
walletTransfers |
[WalletTransfer!]! |
Individual per-payee transfers |
Inputs
input CreateSplitRuleInput {
merchantId: ID!
type: SplitRuleType # fixed | custom
percentBps: Int
fixedAmountMinor: Int
status: SplitRuleStatus # deprecated — lifecycle-controlled, omit it
amountBase: SplitRuleAmountBase # net_settled | gross_payment
allocationMode: SplitRuleAllocationMode # weight | absolute
remainderPolicy: SplitRuleRemainderPolicy
overAllocationPolicy: SplitRuleOverAllocationPolicy
remainderWalletId: Int
effectiveFrom: Time
effectiveTo: Time
lines: [SplitRuleLineInput!]
}
input SplitRuleLineInput {
payeeId: Int
allocationType: SplitRuleAllocationType # percent_bps | fixed_minor | mixed
percentBps: Int
fixedAmountMinor: Int
priority: Int
isEnabled: Boolean
minAmountMinor: Int
maxAmountMinor: Int
}
input SplitRulePreviewInput {
merchantId: ID!
amountMinor: Int!
distribution: [SplitRulePreviewDistributionItem!]! # { payeeRef: { id } | { externalId }, amountMinor }
ruleId: Int # preview a SAVED rule — this is the form that issues a receipt
rule: SplitRulePreviewRuleInput # preview an unsaved shape instead; no receipt
}
input ActivateSplitRuleInput {
id: Int!
previewReceipt: String!
confirmed: Boolean!
}
UpdateSplitRuleInput carries the same fields as CreateSplitRuleInput minus merchantId, plus three sub-operations on lines — createLines, updateLines (each entry needs its lineId), deleteLineIds — and explicit clear flags: clearRemainderWallet, clearEffectiveFrom, clearEffectiveTo.
Worked example — create, preview, activate
A rule that sends 30% of each net-settled payment to payee 101 and 70% to payee 102.
Note
Split arithmetic is minor units throughout:
amountMinor: 10000is €100.00. The inlinepayeeDistributionon a checkout payment takes major units instead — see the warning at the end of this guide.
curl
# Step 0 — login
curl -sc cookies.txt \
-X POST https://api.fynex.ai/api/v1/login/dashboard \
-H "Content-Type: application/json" \
-d '{"email": "you@example.com", "password": "your_password"}'
# Step 1 — create the rule. It is created INACTIVE; do not send `status`.
curl -b cookies.txt \
-X POST https://api.fynex.ai/dashboard/graphql \
-H "Content-Type: application/json" \
-d '{
"query": "mutation CreateSplit($input: CreateSplitRuleInput!) { createSplitRule(input: $input) { id status version lines { id payeeId percentBps priority } } }",
"variables": {
"input": {
"merchantId": "42",
"amountBase": "net_settled",
"remainderPolicy": "to_main_wallet",
"overAllocationPolicy": "fail",
"lines": [
{ "payeeId": 101, "allocationType": "percent_bps", "percentBps": 3000, "priority": 1, "isEnabled": true },
{ "payeeId": 102, "allocationType": "percent_bps", "percentBps": 7000, "priority": 2, "isEnabled": true }
]
}
}
}'
# Step 2 — preview the SAVED rule against a real amount. Returns the receipt.
curl -b cookies.txt \
-X POST https://api.fynex.ai/dashboard/graphql \
-H "Content-Type: application/json" \
-d '{
"query": "query Preview($input: SplitRulePreviewInput!) { previewSplit(input: $input) { ruleId ruleVersion totalAmountMinor totalFeeMinor wouldFailReason activationReceipt activationReceiptExpiresAt allocations { payeeId grossShareMinor feeMinor netAmountMinor } } }",
"variables": {
"input": {
"merchantId": "42",
"ruleId": 7,
"amountMinor": 10000,
"distribution": [
{ "payeeRef": { "id": 101 }, "amountMinor": 3000 },
{ "payeeRef": { "id": 102 }, "amountMinor": 7000 }
]
}
}
}'
# Step 3 — activate with the receipt from step 2 (valid 10 minutes).
curl -b cookies.txt \
-X POST https://api.fynex.ai/dashboard/graphql \
-H "Content-Type: application/json" \
-d '{
"query": "mutation Activate($input: ActivateSplitRuleInput!) { activateSplitRule(input: $input) { activated { id status version } deactivated { id status } } }",
"variables": {
"input": { "id": 7, "previewReceipt": "<activationReceipt from step 2>", "confirmed": true }
}
}'
JavaScript
const BASE = 'https://api.fynex.ai';
async function login(email, password) {
const res = await fetch(`${BASE}/api/v1/login/dashboard`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!res.ok) throw new Error(`Login failed: ${res.status}`);
}
async function gql(query, variables = {}) {
const res = await fetch(`${BASE}/dashboard/graphql`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, variables }),
});
const { data, errors } = await res.json();
if (errors?.length) throw new Error(errors[0].message);
return data;
}
await login('you@example.com', 'your_password');
const merchantId = '42';
// 1. Create — inactive, no `status` field.
const { createSplitRule: rule } = await gql(
`mutation CreateSplit($input: CreateSplitRuleInput!) {
createSplitRule(input: $input) { id status version }
}`,
{
input: {
merchantId,
amountBase: 'net_settled',
remainderPolicy: 'to_main_wallet',
overAllocationPolicy: 'fail',
lines: [
{ payeeId: 101, allocationType: 'percent_bps', percentBps: 3000, priority: 1, isEnabled: true },
{ payeeId: 102, allocationType: 'percent_bps', percentBps: 7000, priority: 2, isEnabled: true },
],
},
}
);
// 2. Preview the saved rule — this is what issues the activation receipt.
const { previewSplit: preview } = await gql(
`query Preview($input: SplitRulePreviewInput!) {
previewSplit(input: $input) {
ruleVersion
totalAmountMinor
totalFeeMinor
wouldFailReason
activationReceipt
allocations { payeeId grossShareMinor feeMinor netAmountMinor }
}
}`,
{
input: {
merchantId,
ruleId: rule.id,
amountMinor: 10000,
distribution: [
{ payeeRef: { id: 101 }, amountMinor: 3000 },
{ payeeRef: { id: 102 }, amountMinor: 7000 },
],
},
}
);
if (preview.wouldFailReason) {
throw new Error(`Rule would not run: ${preview.wouldFailReason}`);
}
// 3. Activate. Deactivates whatever was active, in the same transaction.
const { activateSplitRule: result } = await gql(
`mutation Activate($input: ActivateSplitRuleInput!) {
activateSplitRule(input: $input) {
activated { id status version }
deactivated { id status }
}
}`,
{ input: { id: rule.id, previewReceipt: preview.activationReceipt, confirmed: true } }
);
console.log('Active rule:', result.activated.id, '— stood down:', result.deactivated.map((r) => r.id));
Python
import requests
BASE = "https://api.fynex.ai"
MERCHANT_ID = "42"
session = requests.Session()
session.post(
f"{BASE}/api/v1/login/dashboard",
json={"email": "you@example.com", "password": "your_password"},
).raise_for_status()
def gql(query, variables):
resp = session.post(f"{BASE}/dashboard/graphql", json={"query": query, "variables": variables})
resp.raise_for_status()
body = resp.json()
if "errors" in body:
raise RuntimeError(body["errors"][0]["message"])
return body["data"]
# 1. Create — inactive. Sending `status` is rejected.
rule = gql(
"""
mutation CreateSplit($input: CreateSplitRuleInput!) {
createSplitRule(input: $input) { id status version }
}
""",
{
"input": {
"merchantId": MERCHANT_ID,
"amountBase": "net_settled",
"remainderPolicy": "to_main_wallet",
"overAllocationPolicy": "fail",
"lines": [
{"payeeId": 101, "allocationType": "percent_bps", "percentBps": 3000, "priority": 1, "isEnabled": True},
{"payeeId": 102, "allocationType": "percent_bps", "percentBps": 7000, "priority": 2, "isEnabled": True},
],
}
},
)["createSplitRule"]
# 2. Preview the saved rule — returns the activation receipt.
preview = gql(
"""
query Preview($input: SplitRulePreviewInput!) {
previewSplit(input: $input) {
ruleVersion totalAmountMinor totalFeeMinor wouldFailReason activationReceipt
allocations { payeeId grossShareMinor feeMinor netAmountMinor }
}
}
""",
{
"input": {
"merchantId": MERCHANT_ID,
"ruleId": rule["id"],
"amountMinor": 10000,
"distribution": [
{"payeeRef": {"id": 101}, "amountMinor": 3000},
{"payeeRef": {"id": 102}, "amountMinor": 7000},
],
}
},
)["previewSplit"]
if preview["wouldFailReason"]:
raise RuntimeError(f"Rule would not run: {preview['wouldFailReason']}")
# 3. Activate within 10 minutes of the preview.
result = gql(
"""
mutation Activate($input: ActivateSplitRuleInput!) {
activateSplitRule(input: $input) {
activated { id status version }
deactivated { id status }
}
}
""",
{"input": {"id": rule["id"], "previewReceipt": preview["activationReceipt"], "confirmed": True}},
)["activateSplitRule"]
print("Active rule:", result["activated"]["id"],
"— stood down:", [r["id"] for r in result["deactivated"]])
Why did this payee get 90 and not 95?
Every split execution writes a decision record — a snapshot of the rule
and lines as they were when the split ran, what each line contributed, which
lines did not fire and the machine-readable reason (line_disabled,
no_distribution_weight, zero_allocation), and which policy moved a number
afterwards (clamped_to_min, clamped_to_max, over_allocation_resolved).
Read it over REST with your seller key:
GET /payments-api/v1/payments/{paymentId}/split-decisions
paymentId is your externalOrderRef, the same value the other payment reads
take.
Records come back newest first, and more than one is normal: a re-split
after a correction is a second evaluation, and the first entry is the
allocation in force. An empty list means no split has run for the payment —
it is not a 404. Every amount in a record is an integer in minor units; the
payment it explains still reports amount in major units, so trust the record
for arithmetic.
Editing, replacing and removing rules
Editing the rule that is live. You cannot. Clone it, edit the copy, preview the copy, activate the copy:
mutation Replace($id: Int!) {
cloneSplitRule(id: $id) { id status version } # a new INACTIVE copy
}
Then updateSplitRule on the clone, previewSplit(ruleId:) on the clone, and activateSplitRule — which deactivates the original as part of the same transaction. There is no window in which the seller has two active rules. There is none in which they have none either, unless the clone carries an effectiveFrom in the future (see the scheduling note above).
Editing an inactive rule. updateSplitRule(id, input) supports three sub-operations on lines in one call: createLines, updateLines (each needs its lineId), deleteLineIds. For single-line changes keyed by payee rather than line ID, use upsertSplitRuleLineByPayee / deleteSplitRuleLineByPayee. All of them refuse an active rule.
Turning splitting off. deactivateSplitRule(id:). Setting status: inactive through an update is not the way — the field is rejected.
Deleting. deleteSplitRule(id:) works only on an inactive rule; an active one is refused. Deleting does not reverse split executions that have already posted — inspect splitExecutions before deleting a rule that has run.
Per-payment inline splits
Pass payeeDistribution inside orderData when you create the payment — on POST /payments-api/v1/checkout or POST /payments-api/v1/initialize-payment — to steer that one payment's split. Each element names a payeeId and that payee's share in amountMinor: integer minor units of the payment currency, so 3000 is €30.00. That is the same unit split rules, previews and executions use. The shares must add up to the payment amount.
{
"amount": 100.00,
"currencyCode": "EUR",
"orderData": {
"totalAmount": 100.00,
"payeeDistribution": [
{ "payeeId": 101, "amountMinor": 3000 },
{ "payeeId": 102, "amountMinor": 7000 }
]
}
}
Only the shares are in minor units. The payment amount and orderData.totalAmount stay in major units (100.00 is €100.00).
- Use
amountMinor. The olderamountfield carries the same share in major units (30.00for €30.00). It is deprecated but still accepted, and it behaves exactly as it always has. - Use one unit per distribution. Each element sets exactly one of
amountMinororamount, and every element in the distribution sets the same one. An element that sets both or neither is refused with400, and so is a distribution that mixes the two. - Send positive shares. An
amountMinorof zero or less is refused with400, the same as a zero or negativeamount.
Warning
Read the field name before you send a number. A field ending in
Minortakes minor units: €30.00 is3000. The deprecatedamounttakes major units, like the paymentamountandorderData.totalAmount: €30.00 is30.00. Checkout and initialize-payment both check the shares against the payment amount. A distribution in the wrong unit is a hundred times too large or too small, so it cannot add up. It is refused with400andorderData.payeeDistribution total must equal payment amount, or… must equal orderData.totalAmountwhen you sent a total. That error is about the sum, so check the units before you change the numbers.
Precedence when both are present
payeeDistribution is not a standalone mechanism — it only ever takes effect alongside an active split rule, and which one actually decides the payee shares depends on that rule's allocation mode:
- No active split rule on the seller account. The split is skipped entirely —
payeeDistributionis not read, and nothing is distributed. A persistent rule must exist and be active for a split to run at all. - Exactly one active rule,
absolutemode. The rule's own lines determine every payee's share.payeeDistributionis ignored completely, even if present and non-empty. - Exactly one active rule,
weightmode (the default).payeeDistributionsupplies the shares; the rule contributes only its fee policy on top of them —weightmode never produces a remainder, so a rule's remainder policy has no effect here. An empty or missingpayeeDistributionmeans the split is skipped, same as having no rule at all. - More than one active rule, with a non-empty
payeeDistribution. The split execution fails outright. With an empty or missingpayeeDistribution, the empty-distribution skip above takes precedence instead and the split is silently a no-op success — this is a real, deliberate ordering in the code, not a documentation simplification.
Activation stands down every other active rule in the same transaction, and a unique index in the database backs it, so activating through either surface cannot put a seller in the last case.
Choosing a rule mode — there is no migration path
payeeDistribution is not deprecated and has no removal date: it is how a payment tells a
weight-mode rule how to divide that payment, and it is supported on the same terms as the
rule itself. There is nothing to migrate away from. What you choose is the rule's mode:
- The distribution is the same for every payment — an
absolute-mode rule alone. Its versioned lines are the whole allocation, in integer minor units (3000= €30.00). Stop sendingpayeeDistribution: the rule ignores it. - The distribution varies per payment under one fee policy — a
weight-mode rule, pluspayeeDistributionon every payment you want split, in the units the warning above describes. A payment without one is not split.
Moving from one mode to the other is an ordinary rule change: create the new rule inactive,
preview it, activate it — activation replaces the previous rule atomically. The
Hosted Checkout guide shows payeeDistribution on a checkout request.
See also
- GraphQL Authentication — Obtain the dashboard_session cookie required for all GraphQL calls.
- Payees — Create and manage the payees that appear in your split rule lines.
- Hosted Checkout — Pass payeeDistribution for per-payment inline splits.
- Wallets — Inspect the wallet ledger entries produced by split executions.