Subaccounts & Split Payments
Split a single payment across multiple bank accounts — marketplaces, commission models, revenue shares, vendor payouts. This is the complete field reference: every field states what it does, its units, whether it's required, and which other fields change its behaviour.
Concepts
The invariant that governs everything: the sum of all shares always equals the payment amount, exactly. No money is created or lost — rounding always lands on merchant-main.
| Term | Meaning |
|---|---|
| Subaccount | A named beneficiary — a verified bank account under your merchant account. |
| Merchant-main | Your own account. It receives whatever the subaccounts don't, and it always pays the VAT. |
| Split | How one payment's amount is divided between subaccounts and merchant-main. |
| Share | What one subaccount receives — either a percentage of the amount or a fixed sum. |
| Remainder | amount − Σ subaccount shares. Goes to merchant-main by default. |
| Fee bearer | Who absorbs ValuePay's processing fee inside the split. |
Notes
- All endpoints on this page use your secret API key (
Authorization: Bearer sk_live_...). Checkout initialisation uses your public key instead — see the Authentication guide. - Never send
merchantIdin a request body. It's inferred from your API key and is rejected as an unknown field if sent.
Money Units
Units are not uniform across this API, and it's the single most common source of wrong splits. Check this table before writing any integration code.
| Where | Unit | ₦30.00 is written as |
|---|---|---|
amount at checkout | Naira, up to 2 decimals | 30.00 |
subValue on a subaccount (subType: "flat") | Naira | 30 |
subValue on a subaccount (subType: "percent") | Percent, 0–100, up to 2 decimals | 30 = 30% |
share / shareValue (shareType: "percentage") | Basis points, 0–10000 | 3000 = 30% |
share / shareValue (shareType: "flat") | Kobo | 3000 = ₦30.00 |
amount / fee on /splits/preview | Kobo | 3000 = ₦30.00 |
| Money in responses | Naira numbers | 30.00 |
⚠️ Important
- A subaccount's own
subValue: 30(percent) and a split entry'sshareValue: 30(30 basis points = 0.3%) look identical and mean wildly different things. Percentages inside a split are always basis points.
Response Conventions
Every response follows the same envelope, and every list endpoint wraps its results in a page object.
Success
{ "status": "Success", "data": { }, "message": "..." }
Failure — every 4xx and 5xx
{ "status": "Failed", "errors": [ { "message": "...", "error_code": "INVALID_INPUT", "code": 100000 } ] }
List response shape — GET /v1/subaccounts and GET /v1/splits
{
"status": "Success",
"data": {
"results": [ /* the actual items */ ],
"current_page": 1,
"total_page": 1,
"total_docs": 4,
"has_next": false,
"has_previous": false,
"next_page": null,
"previous_page": null
},
"message": "..."
}
Notes
- Read
data.results, notdata. Treatingdataas the array is the most common first mistake against these endpoints — it yields nothing and looks like an empty account. - Single-resource reads (
GET /v1/subaccounts/{id},GET /v1/splits/{splitCode}) are not wrapped —datais the object itself. - Every endpoint validates the complete set of submitted keys (path params + query string + body). One unrecognised key fails the whole request — e.g.
Extra fields submitted: merchantId, type. Seven params are accepted on every list endpoint without being listed:search,page,limit,from,to,sort_by,order. 0means genuinely zero.nullmeans not known or not applicable. Rendernullas "—", never as "₦0.00" — format on!= null, never on truthiness, or every legitimate zero renders as a bare currency sign.
Create a Subaccount
Registers a verified bank account as a payout beneficiary under your merchant account.
⚠️ Important
- Subaccounts have no
metaDatafield. The API accepts it without error and discards it silently — usedescription, or hold your own reference against the returnedcode. This is specific to subaccounts; themetaDatayou send when initialising a payment is a different field and is retained.
Headers
| Name | Type | Description |
|---|---|---|
| Authorization* | String | Set value to Bearer SECRET_KEY |
| Content-Type* | String | Set value to application/json |
Body Parameters
| Name | Type | Description |
|---|---|---|
| accountNumber* | String | 10–20 chars. Verified by NIBSS name enquiry on create — an unresolvable account is rejected. |
| bankCode* | String | 2–6 chars. NIBSS bank code. Must pair with accountNumber to resolve, or create fails. |
| subType* | String | 'percent' or 'flat'. Declares how subValue is read — changes its meaning and valid range. |
| subValue* | Number | This subaccount's default share, used only in direct-mode checkout. percent: >0 and ≤100, max 2dp. flat: ≥0 Naira, max 2dp. |
| businessName | String | ≤150 chars. Display label — does not override the NIBSS-verified account name, which is what gets paid. |
| description | String | ≤255 chars. Free text for your own records. |
Request Body
{
"accountNumber": "0123456789",
"bankCode": "000014",
"subType": "percent",
"subValue": 30,
"businessName": "Vendor A Ltd",
"description": "Marketplace vendor — electronics"
}Sample Response
{
"status": "Success",
"data": {
"id": "8a1ceac9-fe8a-4ee7-81e7-d3076c0655e7",
"code": "sub_a1b2c3d",
"accountNumber": "0123456789",
"accountName": "VENDOR A LTD",
"bankCode": "000014",
"subType": "percent",
"subValue": 30,
"businessName": "Vendor A Ltd",
"status": "Active"
},
"message": "subaccount created"
}Notes
codeformat issub_+ 7 lowercase base36 characters. Use it wherever a split references a subaccount — bothcodeandidare accepted, butcodeis stable, shorter and safe to store.accountNameis the NIBSS-verified name, not yourbusinessName— this is the name that appears on the beneficiary's statement.- Rejected
subValueexamples forpercent:0,100.5,30.555. Rejected forflat: negative values, 3+ decimals.
List Subaccounts
Retrieves your subaccounts.
Headers
| Name | Type | Description |
|---|---|---|
| Authorization* | String | Set value to Bearer SECRET_KEY |
Query Parameters
| Name | Type | Description |
|---|---|---|
| status | String | 'Active' or 'Inactive'. Closed subaccounts are 'Inactive'. |
| search | String | Matches name / account number. |
| page | Integer | Page number for pagination. |
| limit | Integer | Results per page. |
| from | Datetime | ISO 8601 date. |
| to | Datetime | ISO 8601 date. Rejected without 'from'. |
| sort_by | String | Field to sort by. |
| order | String | 'asc' or 'desc'. |
Sample Response
{
"status": "Success",
"data": {
"results": [
{ "id": "8a1ceac9-fe8a-4ee7-81e7-d3076c0655e7", "code": "sub_a1b2c3d", "accountName": "VENDOR A LTD", "subType": "percent", "subValue": 30, "status": "Active" }
],
"current_page": 1, "total_page": 1, "total_docs": 1,
"has_next": false, "has_previous": false, "next_page": null, "previous_page": null
},
"message": "subaccounts fetched"
}Fetch a Subaccount
Retrieves a single subaccount.
⚠️ Important
- Fetch, update and delete take the UUID
id, not thesub_...code — the path is validated as a UUIDv4 and a code returns400. Thecodeis only for referencing a subaccount inside a split.
Headers
| Name | Type | Description |
|---|---|---|
| Authorization* | String | Set value to Bearer SECRET_KEY |
Path Parameters
| Name | Type | Description |
|---|---|---|
| id* | String | The subaccount's UUID (not its sub_... code). |
Update a Subaccount
Every create field is accepted, all optional, plus schedule.
⚠️ Important
- This endpoint does not accept
statusat all —PUT ... { "status": "Active" }returns400 Extra fields submitted: status. There is no reactivate; useschedule: manualto hold payouts instead of closing a subaccount.
Headers
| Name | Type | Description |
|---|---|---|
| Authorization* | String | Set value to Bearer SECRET_KEY |
| Content-Type* | String | Set value to application/json |
Path Parameters
| Name | Type | Description |
|---|---|---|
| id* | String | The subaccount's UUID. |
Body Parameters
| Name | Type | Description |
|---|---|---|
| accountNumber | String | See Create a Subaccount. |
| bankCode | String | See Create a Subaccount. |
| subType | String | See Create a Subaccount. |
| subValue | Number | Validated against the stored subType if subType is omitted. |
| businessName | String | See Create a Subaccount. |
| description | String | See Create a Subaccount. |
| schedule | String | 'auto' or 'manual'. 'manual' means this subaccount never settles automatically — it waits for a human. |
Notes
- Sending
subValuewithoutsubTypekeeps the storedsubType, andsubValueis validated against that — so changing a subaccount from percent 30 to flat 3000 requires sending both fields.
Delete a Subaccount
A soft close: the subaccount stops appearing in every read, can no longer receive a split, and its history is preserved.
⚠️ Important
- Deletion is terminal. There is no reactivate endpoint, and a closed subaccount is rejected at checkout. To restore a beneficiary you deleted, create a new subaccount — you'll get a new
sub_...code and id, and any split config that referenced the old one must be repointed.
Headers
| Name | Type | Description |
|---|---|---|
| Authorization* | String | Set value to Bearer SECRET_KEY |
Path Parameters
| Name | Type | Description |
|---|---|---|
| id* | String | The subaccount's UUID. |
Notes
- Blocked while a live split config still references the subaccount — the error names the offending split codes. Delete those configs or point them elsewhere first.
Create a Split Configuration
A reusable, named split. Create it once, then reference it at checkout by splitId.
⚠️ Important
- Not accepted — sending any of these returns
400: merchantId (comes from your API key), type (derived from your entries — all percentage → percentage, all flat → flat, mixed → hybrid), rules (removed along with the tiered and rule_based types, which were never implemented).
Headers
| Name | Type | Description |
|---|---|---|
| Authorization* | String | Set value to Bearer SECRET_KEY |
| Content-Type* | String | Set value to application/json |
Body Parameters
| Name | Type | Description |
|---|---|---|
| name* | String | 1–256 chars. Display label. |
| entries* | Array | Non-empty. The subaccount shares — order is irrelevant. |
| entries[].subaccountId* | String | sub_... code or UUID. Must be an active subaccount you own. Duplicates across entries are rejected. |
| entries[].shareType* | String | 'percentage' or 'flat'. Sets the unit of share. Entries may mix types freely within one config. |
| entries[].share* | Integer | ≥0, integer only. percentage → basis points (3000 = 30%). flat → kobo (2000 = ₦20). Decimals are rejected. |
| bearerType | String | 'main' (default) | 'subaccount' | 'all_equal' | 'all_proportional'. Who absorbs the processing fee — see Fees and VAT. |
| bearerSubaccountId | String | Required when bearerType is 'subaccount', ignored otherwise. Must match one of the entries. |
| remainderPolicy | String | 'main' (default) or 'named_subaccount'. Where the leftover goes. |
| remainderSubaccountId | String | Required when remainderPolicy is 'named_subaccount', ignored otherwise. Must match one of the entries. |
Request Body
{
"name": "Standard 70/30",
"bearerType": "main",
"remainderPolicy": "main",
"entries": [
{ "subaccountId": "sub_a1b2c3d", "shareType": "percentage", "share": 3000 },
{ "subaccountId": "sub_e4f5g6h", "shareType": "flat", "share": 2000 }
]
}Sample Response
{
"status": "Success",
"data": {
"id": "split-cfg-test1",
"splitCode": "SPLIT1",
"name": "Standard 70/30",
"currency": "NGN",
"usage": "LIVE",
"type": "hybrid",
"bearerType": "main",
"remainderPolicy": "main",
"entries": [
{ "subaccountId": "sub_a1b2c3d", "shareType": "percentage", "share": 3000 },
{ "subaccountId": "sub_e4f5g6h", "shareType": "flat", "share": 2000 }
],
"isActive": true
},
"message": "split configuration created"
}Notes
splitCodeis what you pass assplitIdat checkout, and as{splitCode}in the URLs below.
The 100% Trap
Percentages must total under 100%, never exactly 100%. VAT is always taken from merchant-main, regardless of your split. A config whose percentages sum to 10000 bps leaves main with ₦0, and every charge against it then fails at checkout.
Checkout failure once main's remainder hits ₦0
Split is not feasible for amount 1000: Fee borne by main (324) exceeds its gross (0)
⚠️ Important
- The single exception: a VAT-exempt merchant whose customer pays the fee owes main nothing, so a 100% split allocates cleanly. Don't design around this — it breaks the moment either condition changes.
Notes
- Creation-time validation is structural only, so a 100%-allocated config saves successfully and only fails later, at payment time. Always leave at least the fee and VAT in main.
remainderPolicy: named_subaccountis not subject to this trap — main first retains exactly what it owes in fee and VAT, and the named subaccount receives the surplus. Main nets zero (the intent) rather than failing.
List Split Configurations
Returns config headers without entries — see Fetch a Split Configuration to read a config's entries.
⚠️ Important
- A UI bound to
entrieson this response will always show empty —entriesis only present onGET /v1/splits/{splitCode}.
Headers
| Name | Type | Description |
|---|---|---|
| Authorization* | String | Set value to Bearer SECRET_KEY |
Sample Response
{
"id": "split-cfg-test1",
"splitCode": "SPLIT1",
"name": "Standard 70/30",
"currency": "NGN",
"usage": "LIVE",
"type": "hybrid",
"bearerType": "main",
"remainderPolicy": "main",
"bearerSubaccountId": null,
"remainderSubaccountId": null,
"isActive": true,
"deletedAt": null,
"createdAt": "2026-07-19T23:32:49.886Z"
}Fetch a Split Configuration
The only endpoint that returns a config's entries.
Headers
| Name | Type | Description |
|---|---|---|
| Authorization* | String | Set value to Bearer SECRET_KEY |
Path Parameters
| Name | Type | Description |
|---|---|---|
| splitCode* | String | The config's splitCode, e.g. SPLIT1. |
Update a Split Configuration
Every field is optional.
Headers
| Name | Type | Description |
|---|---|---|
| Authorization* | String | Set value to Bearer SECRET_KEY |
| Content-Type* | String | Set value to application/json |
Path Parameters
| Name | Type | Description |
|---|---|---|
| splitCode* | String | The config's splitCode. |
Body Parameters
| Name | Type | Description |
|---|---|---|
| name | String | See Create a Split Configuration. |
| entries | Array | Replaces the entry set wholesale — there is no per-entry patch. Omitting an existing entry deletes it. |
| bearerType | String | See Create a Split Configuration. |
| bearerSubaccountId | String | See Create a Split Configuration. |
| remainderPolicy | String | See Create a Split Configuration. |
| remainderSubaccountId | String | See Create a Split Configuration. |
Notes
- Validation runs against the merged result and
typeis re-derived. - New payments pick up a
PATCHimmediately. In-flight payments keep the split instruction that was frozen into theirsplitSnapshotat initialisation — see Reading a Split Result.
Delete a Split Configuration
Soft and terminal.
⚠️ Important
- The config vanishes from every read and from checkout resolution in both LIVE and TEST.
isActiveis a separate flag reserved for a future reversible pause — a deleted config is gone, not "inactive".
Headers
| Name | Type | Description |
|---|---|---|
| Authorization* | String | Set value to Bearer SECRET_KEY |
Path Parameters
| Name | Type | Description |
|---|---|---|
| splitCode* | String | The config's splitCode. |
Preview a Split
Dry-runs the allocation engine. No payment is created, nothing is charged. Use it to render a breakdown before the customer confirms, and to catch an infeasible config before checkout fails.
⚠️ Important
- Preview does not model VAT — it takes amount and fee only, there's no vat parameter. Main's actual net on a real payment is lower than preview shows, by the VAT. See Reading a Split Result for the full response shape and its invariants.
Headers
| Name | Type | Description |
|---|---|---|
| Authorization* | String | Set value to Bearer SECRET_KEY |
| Content-Type* | String | Set value to application/json |
Body Parameters
| Name | Type | Description |
|---|---|---|
| amount* | Integer | Kobo, ≥1. Note: kobo here, unlike amount at checkout, which is Naira. |
| fee | Integer | Kobo, ≥0. Omit to preview without a processing fee. |
| splitCode | String | A saved config to preview. Supply this or directive. |
| directive | Object | An inline split (same shape as the checkout 'split' object) to preview without saving it. |
Request Body
{ "splitCode": "SPLIT1", "amount": 100000, "fee": 280 }Splitting a Payment at Checkout
Choose exactly one of three methods when calling POST /v1/checkouts/inline_initiation (public key) or POST /v1/transactions/initialize (secret key) — see the Transactions API for the rest of that endpoint's fields.
| Method | Field | Value | Use when |
|---|---|---|---|
| Direct | subaccountId | a sub_... code | Routing to one subaccount at its own default share (its stored subValue). |
| Saved | splitId | a splitCode | Reusing a predefined multi-party split. |
| Inline | split | object | Defining a one-off split for this payment only. |
Direct
{ "amount": 1000, "subaccountId": "sub_a1b2c3d", "customer": { "email": "buyer@example.com" } }
Inline
"split": {
"bearerType": "main",
"remainderPolicy": "main",
"entries": [
{ "subaccount": "sub_a1b2c3d", "shareType": "percentage", "shareValue": 3000 },
{ "subaccount": "sub_e4f5g6h", "shareType": "flat", "shareValue": 2000 }
]
}
| Saved config field (§ Create a Split Configuration) | Inline 'split' field |
|---|---|
entries[].subaccountId | entries[].subaccount |
entries[].share | entries[].shareValue |
bearerSubaccountId | bearerSubaccount |
remainderSubaccountId | remainderSubaccount |
⚠️ Important
- Sending more than one of
subaccountId,splitId,splitreturns400— "Provide only one of subaccountId, splitId, or split — they are mutually exclusive".
Body Parameters
| Name | Type | Description |
|---|---|---|
| amount* | Number | ≥50, max 2 decimals, Naira. The base every share is computed from. |
| chargesBearer | String | Who pays the ValuePay processing fee — the customer or the merchant. When the merchant pays, bearerType (on the split) decides who inside the split absorbs it. |
| subaccountId | String | Direct mode — see table above. Mutually exclusive with splitId and split. |
| splitId | String | Saved mode — a splitCode. Mutually exclusive with subaccountId and split. |
| split | Object | Inline mode — a one-off split object. Mutually exclusive with subaccountId and splitId. Uses different field names than a saved config — see table above. |
Notes
- Units and semantics inside
splitare identical to a saved config — bps for percentage, kobo for flat, integers only. Only the field names differ. Using a saved-config name insidesplitreturns400. - An empty string counts as absent, so
"splitId": ""will not trip the exclusivity check. - Splits are NGN-only in practice.
Fees and VAT
Two independent settings decide the money out. They compose; neither overrides the other.
| chargesBearer setting | Effect |
|---|---|
| Customer pays | The customer is charged amount + fee. No split party is charged the fee. |
| Merchant pays | The fee is deducted from whoever bearerType names. |
| bearerType (only applies when the merchant pays) | Who bears the processing fee |
|---|---|
main (default) | Merchant-main. |
subaccount | One designated subaccount — requires bearerSubaccountId / bearerSubaccount. |
all_equal | Split equally across the subaccounts. |
all_proportional | Split across subaccounts in proportion to their gross. |
| Case — ₦200 payment, fee ₦2.80, VAT ₦0.21, split: sub_a1b2c3d 30%, remainder → main | Subaccount receives | Main receives | Why |
|---|---|---|---|
| Customer pays fee | ₦60.00 | ₦139.79 | Customer paid the fee; only VAT comes off main (₦140 − ₦0.21). |
| Merchant pays, bearerType: main | ₦60.00 | ₦136.99 | Fee and VAT both off main (₦140 − ₦2.80 − ₦0.21). |
| Merchant pays, bearerType: subaccount | ₦57.20 | ₦139.79 | Fee off the chosen subaccount (₦60 − ₦2.80); VAT still off main. |
Notes
- VAT (7.5% of the fee) is deducted from merchant-main in every configuration — regardless of chargesBearer, regardless of bearerType, and it is never charged to a subaccount. It's 0 for VAT-exempt merchants.
platformexists in the bearerType enum but is not merchant-settable — it waives ValuePay's fee entirely and is an internal arrangement. Sending it returns 400.
Reading a Split Result
Everything here is available with your secret key on GET /v1/transactions/{transactionRef}. Four fields carry the split — all are null on a payment that carried no split.
| Field | Type | Meaning |
|---|---|---|
subaccountId | string | null | Set in direct mode — the value exactly as you sent it, sub_... code or UUID. |
splitId | string | null | Set in saved mode — the splitCode you referenced. |
chargesBearer | string | null | Who paid the processing fee on this payment. |
splitSnapshot | object | null | The frozen split instruction — see below. |
⚠️ Important
splitSnapshot.entries[].subaccountIdis always the canonical UUID, even when you posted asub_...code — the resolver normalises it. Map by UUID, or keep your own code↔id lookup.
Notes
- The snapshot is written at initialisation and never changes. Editing or deleting the split config afterwards does not alter it, and payout reads the snapshot rather than the live config — a config edit can never retroactively re-allocate a payment already in flight.
Split Preview Response Shape
The transaction only records the instruction, not the resulting Naira. To render a per-party breakdown, call POST /v1/splits/preview with the same amount and fee — it runs the same allocation engine as capture, so identical inputs give identical numbers. The response carries two objects: the snapshot that would be frozen, and the result of allocating it.
| Field | Type | Meaning |
|---|---|---|
distributions[].subaccountId | string | Canonical UUID, or the literal "MAIN" for your own account. |
distributions[].isMain | boolean | true on the merchant-main row. Prefer this over string-matching "MAIN". |
distributions[].shareType | string | 'percentage' | 'flat' | 'remainder'. 'remainder' appears only on the row that absorbed the leftover — it's not a value you can send. |
distributions[].grossAmount | integer (kobo) | Allocated to this party, before its borne fee. |
distributions[].feeAmount | integer (kobo) | Fee borne by this party. 0 for every subaccount when bearerType is main. |
distributions[].netAmount | integer (kobo) | grossAmount − feeAmount — what is actually payable. |
totalGross | integer (kobo) | Always equals the amount you sent. |
totalFee / totalNet | integer (kobo) |
Sample Response
{
"status": "Success",
"data": {
"snapshot": { "source": "config", "splitId": "SPLIT1", "bearerType": "main",
"remainderPolicy": "main", "entries": [ /* ... */ ], "frozenAt": "2026-08-17T09:12:44.201Z" },
"result": {
"distributions": [
{ "subaccountId": "8a1ceac9-fe8a-4ee7-81e7-d3076c0655e7", "isMain": false,
"shareType": "percentage", "shareValue": 3000,
"grossAmount": 30000, "feeAmount": 0, "netAmount": 30000 },
{ "subaccountId": "MAIN", "isMain": true,
"shareType": "remainder", "shareValue": 0,
"grossAmount": 70000, "feeAmount": 301, "netAmount": 69699 }
],
"totalGross": 100000, "totalFee": 301, "totalNet": 99699
}
}
}Notes
- Every amount here is kobo, matching the amount you sent to preview — not Naira like checkout.
- Invariants worth asserting in your own tests:
totalGross === amount, and per rowgrossAmount − feeAmount === netAmount.
Finding the Payments That Used a Split
Both filters match the value stored on the transaction — the form you sent at checkout.
⚠️ Important
- Settled amounts, subaccount balances and payout statements are not on the public API — they are dashboard features. Build reconciliation on this section and your own records; talk to your account manager for programmatic settlement data.
Query Parameters
| Name | Type | Description |
|---|---|---|
| subaccountId | String | e.g. sub_a1b2c3d — matches transactions charged with that exact identifier form. |
| splitId | String | e.g. SPLIT1. |
Notes
- Filtering by UUID will not find payments you created with a sub_... code, or the reverse.
- Combines with the usual status, from/to, page, limit, sort_by, order. Also available on
GET /v1/transactions/exports/csv.
Scenarios
Every figure below is the actual output of the allocation engine for a ₦1,000 payment with a ValuePay fee of ₦3.01 and VAT of ₦0.23, merchant paying the fee, unless noted otherwise. A party's feeAmount of ₦3.24 is the fee plus VAT.
Marketplace — one vendor per order
Goal: the vendor keeps 90%, you keep 10% commission. Create the vendor as a subaccount with subType: percent, subValue: 90, then charge with subaccountId — no split config needed.
| chargesBearer | Vendor gets | You get | Why |
|---|---|---|---|
| Customer pays fee | ₦900.00 | ₦99.77 | Customer was charged ₦1,003.01; only VAT comes off you. |
| Merchant pays fee | ₦900.00 | ₦96.76 | Fee and VAT off your remainder. |
How far you can push the commission down — your remainder must cover ₦3.24 (fee + VAT), and the engine rejects the charge the moment it cannot. The ceiling moves with the payment size, because the fee does:
| Vendor share | Your remainder | Result |
|---|---|---|
| 90% | ₦100.00 | fine — you net ₦96.76 |
| 99% | ₦10.00 | still fine — you net ₦6.76 |
| 99.7% | ₦3.00 | 400 — Fee borne by main (324) exceeds its gross (300) |
| 100% | ₦0.00 | 400 — same error, gross 0 |
A flat platform fee instead of a percentage
Goal: you take ₦50 per order regardless of basket size. Take a flat entry of 5000 kobo to your own collection subaccount and send the remainder to the vendor with remainderPolicy: named_subaccount. The remainder subaccount must also appear in entries — add the vendor with shareType: "flat", "share": 0; the remainder is what actually pays them.
| Basket | Your ₦50 cut | Vendor (remainder) | Merchant-main |
|---|---|---|---|
| ₦1,000 | ₦50.00 | ₦946.76 | ₦0.00 |
| ₦200 | ₦50.00 | ₦146.76 | ₦0.00 |
| ₦60 | ₦50.00 | ₦6.76 | ₦0.00 |
Your ₦50 arrives whole — flat means flat, and the fee does not touch it. Merchant-main retains exactly its ₦3.24 obligation out of the remainder and nets zero; it's the vendor, not your platform cut, that absorbs the cost of a small basket.
Multi-party — vendor, logistics, affiliate
One saved config with three entries and remainderPolicy: main, referenced by splitId on every charge. PATCH the percentages later and new payments pick the change up immediately, while in-flight ones keep their frozen snapshot.
| Party | Entry | Gross |
|---|---|---|
| Vendor | percentage 6000 bps | ₦600.00 |
| Logistics | flat 15000 kobo | ₦150.00 |
| Affiliate | percentage 500 bps | ₦50.00 |
| You (remainder) | — | ₦196.76 |
Making a subaccount absorb the fee
Goal: the vendor, not you, carries ValuePay's cost. Set bearerType: subaccount with bearerSubaccountId, or spread it across every subaccount — all_proportional in proportion to gross, all_equal in equal parts. Using the multi-party split above:
| bearerType | Vendor (60%) | Logistics (₦150) | Affiliate (5%) | You |
|---|---|---|---|---|
| main (default) | ₦600.00 | ₦150.00 | ₦50.00 | ₦196.76 |
| subaccount — vendor | ₦596.99 | ₦150.00 | ₦50.00 | ₦199.77 |
| all_proportional | ₦597.73 | ₦149.44 | ₦49.82 | ₦199.77 |
| all_equal | ₦598.99 | ₦149.00 | ₦49.00 | ₦199.77 |
VAT never moves — in every row it comes off you, which is why your column reads ₦199.77 and not ₦200.00 even when a subaccount bears the entire fee. Note what all_equal does to a small party: the affiliate on ₦50 pays the same ₦1.00 as the vendor on ₦600. Use all_proportional unless equal shares of the cost are genuinely what you want.
One-off splits
Goal: the parties differ on every order, so nothing is worth saving. Use the inline split object — no config is created and there's nothing to clean up. Feasibility is checked at initialize, not at settlement, against the real amount plus the estimated fee and VAT. An impossible split fails immediately and no payment is created — see Validation Reference for every rejection reason.
Holding one party's money back
Goal: pause payouts to a subaccount without stopping its sales. PUT /v1/subaccounts/{id} with schedule: manual — the subaccount keeps accruing on every payment; it simply never settles automatically, and release becomes a manual step. Allocation, the split itself and the customer experience are unchanged, only payout timing moves.
Validation Reference
Everything that returns 400. The last two rows are checked at payment time, not at config creation — a structurally valid config can still be infeasible for a given amount. Use /v1/splits/preview to catch it early, remembering that preview omits VAT.
| Condition | Message |
|---|---|
| Unknown field anywhere in the request | Extra fields submitted: ... |
| More than one split method | Provide only one of subaccountId, splitId, or split ... |
| Amount below the floor | Amount must be at least 50 |
| More than 2 decimals on amount | Amount can only have up to two decimal places |
| Non-integer share / shareValue | entry share must be a non-negative integer (bps or kobo) |
| Percentages summing above 100% | percentage shares exceed 100% (11000 bps) |
| Duplicate subaccount in one split | duplicate subaccount in split: <id> |
| bearerType: subaccount whose bearer is not an entry | bearerSubaccountId must reference an entry when bearerType=subaccount |
| remainderPolicy: named_subaccount whose subaccount is not an entry | remainderSubaccountId must reference an entry when remainderPolicy=named_subaccount |
| bearerType: platform | Invalid bearerType |
| Empty entries | entries must be a non-empty array (route) / split requires at least one entry (engine) |
| 'to' without 'from' on a list | 'to' cannot be provided without 'from' |
| Unknown subaccount at checkout | subaccount ... not found |
| Closed subaccount referenced at checkout | subaccount ... is closed and cannot receive splits |
| Subaccount with no share configured | subaccount ... is not configured for splits (set feeType/feeValue) |
| Deleting a subaccount a live split config still uses | subaccount is referenced by split configuration(s) ... — delete them before deleting this subaccount |
| status sent to PUT /v1/subaccounts/{id} | Extra fields submitted: status — there is no reactivate |
| Flat shares exceeding the amount | Split is not feasible for amount ...: flat shares (200000) exceed amount (100000) |
| Main's remainder cannot cover fee + VAT | Split is not feasible for amount ...: Fee borne by main (324) exceeds its gross (300) |
Refunds
A refund is borne by the merchant: on refund you are debited the full refunded amount including the processing fee. ValuePay does not return its fee — standard across the industry.
Integration Checklist
Before you ship a split-payments integration, confirm each of these.
Notes
- Units correct everywhere — bps in splits, Naira at checkout, kobo in preview
- Not sending merchantId, type or rules to the split config endpoints
- Percentages total under 100%, leaving room for VAT
- Exactly one split method per payment
- Inline field names, not saved-config names, inside split
- Fetching a config by {splitCode} to get entries — the list omits them
- /splits/preview before charging, to surface infeasible splits early
- Mapping splitSnapshot.entries[].subaccountId by UUID — it's never the sub_... code you sent
- Filtering /v1/transactions with the same identifier form you charged with
- Not depending on settled amounts or balances from the public API — they're dashboard-only
- Handling deletion as terminal; using schedule: manual when you only mean "pause"
ON THIS PAGE
© Copyright 2026