Skip to content
In this section

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's orderData.payeeDistribution is ignored.
  • weight (the default) — the payment's orderData.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. payeeDistribution is not a standalone split mechanism: it is the per-payment input to a weight-mode rule. An absolute-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 on POST /dashboard/graphql (dashboard_session cookie 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 status cannot be set. Every rule is created inactive, previewed against a real amount, and then activated with the receipt the preview returns. Sending status: "active" to createSplitRule or updateSplitRule is 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.

Activation is an atomic swap: it activates this rule and deactivates the previous one.
ruleIdreceiptcreateSplitRuleStored inactivepreviewSplit(ruleId:)Returns activationReceiptvalid 10 minutesactivateSplitRulepreviewReceipt, confirmed: trueatomic swap
  1. Create it inactive. createSplitRule stores the rule and its lines. Omit status — the field is deprecated and any value other than inactive is rejected. Nothing runs against payments yet.

  2. Preview the saved rule. previewSplit with ruleId set runs the rule against an amount and a distribution you supply, and returns the exact allocations it would produce — plus an activationReceipt valid for 10 minutes. Pass ruleId or an inline rule, never both; only the ruleId form issues a receipt. If the rule cannot run, wouldFailReason says why and no receipt is issued.

  3. Activate with the receipt. activateSplitRule takes the rule id, the previewReceipt and confirmed: 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 effectiveFrom and has not started yet. That is what deactivated reports. A unique index in the database backs the same invariant.
  • Active rules are immutable. updateSplitRule, upsertSplitRuleLineByPayee and deleteSplitRuleLineByPayee all refuse an active rule: "active split rules are immutable; clone the rule, edit the inactive copy, preview it, then activate it". Use cloneSplitRule to 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 (422 on activation). The live split would otherwise refuse that payee after the payment.
  • Active rules cannot be deleted. deleteSplitRule refuses while the rule is active. Deactivate it first, or activate its replacement — which deactivates it for you.

Note

scheduleSplitRule is disabled and returns an error. To schedule, set effectiveFrom / effectiveTo on 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's effectiveFrom. Until that date no rule applies, and payments that settle in the gap stay unsplit on the main wallet. effectiveTo must be later than effectiveFrom. Activating a rule whose effectiveTo has 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 weight rule of type: fixed with 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's percentBps and fixedAmountMinor apply to every payee's share alike.
  • type: custom: each line carries its own percentBps and fixedAmountMinor. 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 custom line 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_priority 7000 and 3000: priority 1 is paid in full, priority 2 gets what is left.
scale_down_all 5833 and 4167: both scaled to fit, the leftover unit by largest remainder.

scale_down_percent keeps 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: 500 and a 50 % line with maxAmountMinor: 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 weights with 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_wallet or remainder_wallet;
  • sellerRetainedMinor — the seller's own share: remainderMinor plus totalFeeMinor.

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_uncovered review. 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 each SplitExecution stores the ruleVersion it 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: 10000 is €100.00. The inline payeeDistribution on 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 older amount field carries the same share in major units (30.00 for €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 amountMinor or amount, and every element in the distribution sets the same one. An element that sets both or neither is refused with 400, and so is a distribution that mixes the two.
  • Send positive shares. An amountMinor of zero or less is refused with 400, the same as a zero or negative amount.

Warning

Read the field name before you send a number. A field ending in Minor takes minor units: €30.00 is 3000. The deprecated amount takes major units, like the payment amount and orderData.totalAmount: €30.00 is 30.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 with 400 and orderData.payeeDistribution total must equal payment amount, or … must equal orderData.totalAmount when 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 — payeeDistribution is 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, absolute mode. The rule's own lines determine every payee's share. payeeDistribution is ignored completely, even if present and non-empty.
  • Exactly one active rule, weight mode (the default). payeeDistribution supplies the shares; the rule contributes only its fee policy on top of them — weight mode never produces a remainder, so a rule's remainder policy has no effect here. An empty or missing payeeDistribution means 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 missing payeeDistribution, 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 sending payeeDistribution: the rule ignores it.
  • The distribution varies per payment under one fee policy — a weight-mode rule, plus payeeDistribution on 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.