Connect (multi-party platforms) integration guide
Audience. Platforms that take payments on behalf of multiple sub-merchants — marketplaces, SaaS-on-SaaS billing, software platforms with embedded checkout. Covers the three-resource Connect surface (
ConnectedAccount,Transfer,PlatformFee), the onboarding lifecycle, and the charge-driven split flow.
TL;DR
NinjaPay Connect uses a familiar three-resource model:
ConnectedAccount— the sub-merchant. Three account types:STANDARD/EXPRESS/CUSTOM.Transfer— fund movement from your platform balance to a connected account’s external wallet.PlatformFee— your cut of a charge made via a connected account (anApplicationFeeequivalent, renamed for clarity).
Charge-driven splits work via applicationFeeAmount + transferDataDestination on the parent PaymentIntent — settlement materializes a paired PlatformFee + Transfer ledger entry automatically.
When to use Connect
Use Connect when you’re a platform taking payments and routing some portion to another business. Examples:
- Marketplaces (Etsy-like): customer pays you, you keep a fee, the seller gets the rest.
- SaaS billing: your customer (a business) pays, you keep a SaaS fee, their integration partner gets a slice.
- Embedded checkout: another company uses your storefront infrastructure; you settle to them and keep a platform fee.
Don’t use Connect for ordinary single-party charges — client.paymentIntents.create() alone is enough.
Setup
Connect is gated by Organization.connectEnabled = true. Your org enables Connect via the dashboard onboarding (or a support request for enterprise tiers). Once enabled, the client.connectedAccounts, client.transfers, and client.platformFees resources become live.
ConnectedAccount lifecycle
CREATED (account row exists; merchant not onboarded)
│
├── submit-onboarding ──▶ PENDING (KYC submitted; awaiting review)
│ │
│ ├── approve ──▶ ACTIVE
│ └── reject ──▶ REJECTED (terminal)
│
├── approve ─────────────▶ ACTIVE (CUSTOM type — no onboarding step)
│
â–¼
ACTIVE
├── restrict ──▶ RESTRICTED ──▶ ACTIVE (lift)
├── disable ──▶ DISABLED ──▶ ACTIVE (lift)
└── disable ──▶ DISABLED ──▶ DISABLED (terminal soft delete)Account types differ in which transitions you control:
STANDARD— sub-merchant onboards via NinjaPay-hosted KYC. They submit, you approve.EXPRESS— light onboarding (basic info only). Same submit-then-approve flow.CUSTOM— your platform owns the KYC flow. Skipsubmit-onboarding; transition straight fromCREATEDtoACTIVEviaapprove.
Resource shapes
ConnectedAccount
{
"id": "acct_2c4e8f1a9b3d456",
"platformOrgId": "org_platform_xyz",
"connectedOrgId": "org_submerchant_abc",
"accountType": "STANDARD",
"status": "ACTIVE",
"businessName": "Acme Co",
"country": "US",
"currency": "USDC",
"externalReceiverWallet": "Hk7…wallet……",
"capabilities": ["TRANSFERS"],
"kycRefId": null,
"metadata": {},
"createdAt": "2026-05-03T00:00:00.000Z",
"updatedAt": "2026-05-03T00:00:00.000Z"
}externalReceiverWalletis the Solana address that split-routed funds land in. The connected account does not need to be a NinjaPay-onboarded org —connectedOrgIdis optional.capabilitiesis an enum-array.TRANSFERSis required forclient.transfers.create.PAYMENTSis required for charge-driven splits (Phase 4.4.δ).
Transfer
{
"id": "tr_2c4e8f1a9b3d456",
"connectedAccountId": "acct_xxx",
"paymentIntentId": "pi_zzz",
"amount": "47.500000",
"currency": "USDC",
"status": "COMPLETED",
"transferTxSig": "5kQy…",
"completedAt": "2026-05-03T00:00:30.000Z",
...
}State machine: PENDING → COMPLETED | FAILED → REVERSED.
paymentIntentIdis optional (manual platform-payouts have no source charge).transferTxSigis null for off-chain ledger movement; populated when an on-chain split routes the transfer via the router program (Phase 4.4.δ).
PlatformFee
{
"id": "pf_2c4e8f1a9b3d456",
"paymentIntentId": "pi_zzz",
"amount": "2.500000",
"amountRefunded": "0.000000",
"currency": "USDC",
"status": "COLLECTED",
...
}State machine: PENDING → COLLECTED → REFUNDED. Partial refunds bump amountRefunded and stay COLLECTED until cumulative refunds equal the original amount.
Endpoints
Onboarding
POST /v1/connected_accounts
{
"accountType": "STANDARD",
"businessName": "Acme Co",
"country": "US",
"externalReceiverWallet": "Hk7…",
"capabilities": ["TRANSFERS"]
}
POST /v1/connected_accounts/:id/submit-onboarding
POST /v1/connected_accounts/:id/approve
POST /v1/connected_accounts/:id/restrict { "statusReason": "..." }
POST /v1/connected_accounts/:id/disable { "statusReason": "..." }
POST /v1/connected_accounts/:id/lift { "statusReason": "..." }
POST /v1/connected_accounts/:id/reject { "statusReason": "..." }Capabilities are managed separately:
POST /v1/connected_accounts/:id/capabilities { "capability": "PAYMENTS" }
DELETE /v1/connected_accounts/:id/capabilities/:capManual platform-payout (non-charge-driven)
POST /v1/transfers
{
"connectedAccountId": "acct_xxx",
"amount": "100.000000",
"description": "Weekly settlement payout"
}
POST /v1/transfers/:id/mark-completed { "transferTxSig": "5kQy…" }
POST /v1/transfers/:id/mark-failed { "failureReason": "RPC stalled" }
POST /v1/transfers/:id/reverse { "reason": "Duplicate payout" }Same two-step lifecycle as refunds: create row, then push the on-chain transfer tx, then close the loop with mark-completed.
Charge-driven split (Phase 4.4.δ)
The cleanest pattern. Pass applicationFeeAmount + transferDataDestination on the parent PaymentIntent:
const intent = await client.paymentIntents.create({
amount: '50.000000',
currency: 'USDC',
applicationFeeAmount: '2.500000', // platform's cut (5%)
transferDataDestination: 'acct_seller', // where the rest goes
metadata: { order_id: 'order_42' },
});When the intent settles (PAID/CLAIMED/SETTLEMENT_CONFIRMED), call materialize to create the paired ledger entries:
await fetch(`/v1/connect/payment_intents/${intent.id}/settle`, {
method: 'POST',
headers: { Authorization: `Bearer ${jwt}` },
});This creates one PlatformFee row (your $2.50) + one Transfer row (the $47.50 to the connected account). Idempotent — re-runs return the existing rows + created: false.
Constraints checked at materialize time:
- Platform must have
connectEnabled=true. - PaymentIntent must belong to the platform org.
applicationFeeAmountmust be <intent.amount.- ConnectedAccount must exist and not be REJECTED.
Refund + dispute on Connect-aware charges
When you refund a Connect-aware PaymentIntent, you choose how to reverse the fee:
// 1. Refund the customer (standard refund flow):
await client.refunds.create({ paymentIntentId: 'pi_zzz', amount: '50.000000', reason: 'REQUESTED_BY_CUSTOMER' });
// 2. Refund the platform fee (your cut comes back to the platform):
await client.platformFees.refund(platformFee.id, { amount: '2.500000' });
// 3. Reverse the transfer (the connected account's portion comes back):
await client.transfers.reverse(transfer.id, { reason: 'Customer refund' });The three operations are independent — you can refund the customer + fee without reversing the transfer (the connected account keeps their portion), or reverse only the transfer (the customer keeps the goods, the account loses the funds). Industry-standard semantics.
Webhook events
Connect surfaces emit four event families:
| Event | Fires when |
|---|---|
transfer.created | Manual POST /v1/transfers accepted, or charge-driven split materialized. |
transfer.paid | mark-completed — the on-chain tx confirmed. |
transfer.failed | mark-failed |
transfer.reversed | reverse |
application_fee.created | Charge-driven split materialized a PlatformFee. |
application_fee.refunded | client.platformFees.refund issued (full or partial). |
ConnectedAccount lifecycle events are queued for Phase 4.4.ε; for now, subscribe to the audit log (POST /v1/audit-logs?action=connected_account.*) for transition signals.
Sample flow — marketplace transaction
import { NinjaPayClient } from '@ninjapay/sdk';
const client = new NinjaPayClient({ apiKey: process.env.NINJAPAY_API_KEY });
// 1. Onboarding (one-time per seller).
const seller = await client.connectedAccounts.create({
accountType: 'STANDARD',
businessName: "Alice's Bookstore",
country: 'US',
externalReceiverWallet: 'Hk7…aliceWallet…',
capabilities: ['TRANSFERS', 'PAYMENTS'],
});
// Seller completes hosted KYC.
await client.connectedAccounts.submitOnboarding(seller.id);
// You approve.
await client.connectedAccounts.approve(seller.id);
// 2. Customer buys a $50 book; your marketplace keeps $2.50.
const intent = await client.paymentIntents.create({
amount: '50.000000',
currency: 'USDC',
applicationFeeAmount: '2.500000',
transferDataDestination: seller.id,
description: "Book: 'Solana for Cats'",
metadata: { order_id: 'order_42', sku: 'book-001' },
});
const link = await client.paymentLinks.create({ paymentIntentId: intent.id });
// Customer pays via link.hostedUrl.
// 3. Subscribe to payment_intent.paid + application_fee.created
// + transfer.paid in your webhook handler. Reconcile against
// the order_id metadata.
// 4. (Or) explicitly materialize after settlement:
await fetch(`/v1/connect/payment_intents/${intent.id}/settle`, {
method: 'POST',
headers: { Authorization: `Bearer ${jwt}` },
});
// At this point: PlatformFee $2.50 (status COLLECTED) + Transfer
// $47.50 to seller's wallet (status COMPLETED) on the ledger.Operator surfaces
NinjaPay operators have cross-org visibility into:
/dashboard/admin/refunds— including refunds against Connect-aware charges.- The audit log shows every
connected_account.*transition.
The operator-side counts strip on the operator dashboards (Phase 7.6.ζ.6) tracks platform-wide stuck Transfers / pending PlatformFees; if your Connect integration is behaving normally, those should sit at zero.
Related
apps/api/src/routes/connected-accounts.ts— connected-account lifecycle.apps/api/src/routes/transfers.ts— transfer endpoints.apps/api/src/routes/platform-fees.ts— platform-fee endpoints.- Webhook integration guide — full event taxonomy including transfer + application_fee.
- Refunds integration guide — for the refund + transfer-reverse coordination on Connect charges.
- Migration guide —
ApplicationFee→PlatformFeemapping noted there.