Skip to Content
Devnet open · mainnet by application — these docs ship with the pre-alpha release.

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 (an ApplicationFee equivalent, 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. Skip submit-onboarding; transition straight from CREATED to ACTIVE via approve.

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" }
  • externalReceiverWallet is the Solana address that split-routed funds land in. The connected account does not need to be a NinjaPay-onboarded org — connectedOrgId is optional.
  • capabilities is an enum-array. TRANSFERS is required for client.transfers.create. PAYMENTS is 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.

  • paymentIntentId is optional (manual platform-payouts have no source charge).
  • transferTxSig is 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/:cap

Manual 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.
  • applicationFeeAmount must 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:

EventFires when
transfer.createdManual POST /v1/transfers accepted, or charge-driven split materialized.
transfer.paidmark-completed — the on-chain tx confirmed.
transfer.failedmark-failed
transfer.reversedreverse
application_fee.createdCharge-driven split materialized a PlatformFee.
application_fee.refundedclient.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.