Payouts

Overview

Payouts allow you to transfer funds from a fiat mode virtual account to a recipient's bank account via WIRE or SWIFT transfer. The payout process sends USD from the virtual account to a pre-configured recipient.

Important:

  • Payouts are only available for US_BANK virtual accounts in fiat mode (accounts created without a destination)
  • Before initiating a payout, you must create a recipient of type WIRE or SWIFT
  • Crypto mode accounts automatically convert deposits and do not support manual payouts
  • MX_SPEI accounts do not support payouts

Prerequisites

Before initiating a payout:

  1. Virtual account must be in fiat mode - Created without a destination field
  2. Virtual account type must be US_BANK - MX_SPEI accounts cannot initiate payouts
  3. Recipient must exist - Create a WIRE or SWIFT recipient first
  4. Sufficient balance - Available balance must cover the payout amount plus fees

Preview Payout (Get Fees)

Before creating a payout, use the preview endpoint to see the fee breakdown and the amount the recipient will receive.

Endpoint

POST /v1/virtual-accounts/{id}/payout/preview

Headers

HeaderRequiredDescription
AuthorizationYesBearer token from authentication
x-api-keyYesYour API key
Content-TypeYesMust be application/json

Request Body

{
  "amount": "1000.00",
  "recipient_id": "550e8400-e29b-41d4-a716-446655440020"
}

Example Request

curl -X POST https://api.balampay.com/v1/virtual-accounts/550e8400-e29b-41d4-a716-446655440003/payout/preview \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "x-api-key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": "1000.00",
    "recipient_id": "550e8400-e29b-41d4-a716-446655440020"
  }'

Response

{
  "amount": "1000.00",
  "currency": "USD",
  "fees": {
    "base_fees": {
      "fixed_fee": 15,
      "percentage_fee": 0.5,
      "fx_markup": 0
    },
    "client_markup": {
      "fixed_fee": 5,
      "percentage_fee": 0,
      "fx_markup": 0
    },
    "total_fees": 25
  },
  "recipient_amount": "975.00",
  "recipient_currency": "USD"
}

Fee Components

ComponentDescription
base_fees.fixed_feePlatform fixed fee
base_fees.percentage_feePlatform percentage fee
base_fees.fx_markupPlatform FX markup (if currency conversion applies)
client_markup.fixed_feeYour configured fixed markup
client_markup.percentage_feeYour configured percentage markup
client_markup.fx_markupYour configured FX markup
total_feesSum of all fees

Tip: Use the preview endpoint to display fees to users before they confirm the payout.


Create Payout

Endpoint

POST /v1/virtual-accounts/{id}/payout

Headers

HeaderRequiredDescription
AuthorizationYesBearer token from authentication
x-api-keyYesYour API key
Content-TypeYesMust be application/json
idempotency-keyYesUnique identifier to prevent duplicate payouts

Request Body

{
  "amount": "1000.00",
  "recipient_id": "550e8400-e29b-41d4-a716-446655440020"
}

Request Fields

FieldTypeRequiredDescription
amountstringYesAmount in USD to send (must not exceed available balance minus fees)
recipient_idstringYesUUID of the recipient (must be type WIRE or SWIFT)

Example: Payout via WIRE

curl -X POST https://api.balampay.com/v1/virtual-accounts/550e8400-e29b-41d4-a716-446655440003/payout \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "x-api-key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -H "idempotency-key: payout-$(uuidgen)" \
  -d '{
    "amount": "1000.00",
    "recipient_id": "550e8400-e29b-41d4-a716-446655440020"
  }'

Response

{
  "id": "po_550e8400-e29b-41d4-a716-446655440010",
  "virtual_account_id": "550e8400-e29b-41d4-a716-446655440003",
  "recipient_id": "550e8400-e29b-41d4-a716-446655440020",
  "amount": "1000.00",
  "currency": "USD",
  "status": "pending",
  "fees": {
    "base_fees": {
      "fixed_fee": 15,
      "percentage_fee": 0.5,
      "fx_markup": 0
    },
    "client_markup": {
      "fixed_fee": 5,
      "percentage_fee": 0,
      "fx_markup": 0
    },
    "total_fees": 25
  },
  "recipient_amount": "975.00",
  "recipient_currency": "USD",
  "created_at": "2024-01-15T14:30:00Z"
}

Response Fields

FieldTypeDescription
idstringUnique payout ID
virtual_account_idstringThe virtual account ID
recipient_idstringThe recipient receiving the funds
amountstringAmount being sent in USD
currencystringSource currency (USD)
statusstringPayout status
feesobjectFee breakdown for the payout
fees.base_feesobjectPlatform fees
fees.base_fees.fixed_feenumberPlatform fixed fee
fees.base_fees.percentage_feenumberPlatform percentage fee
fees.base_fees.fx_markupnumberPlatform FX markup
fees.client_markupobjectClient-configured markup
fees.client_markup.fixed_feenumberClient fixed markup
fees.client_markup.percentage_feenumberClient percentage markup
fees.client_markup.fx_markupnumberClient FX markup
fees.total_feesnumberTotal fees deducted
recipient_amountstringAmount the recipient will receive (after fees)
recipient_currencystringCurrency the recipient will receive
created_atstringISO 8601 timestamp of creation
updated_atstringISO 8601 timestamp of last update

Fee Calculation

Fees are calculated automatically. Use the preview endpoint to see the exact breakdown before confirming a payout.

Example:

Payout Amount:           $1,000.00 USD

Base Fees:
  Fixed Fee:             $15.00
  Percentage Fee (0.5%): $5.00
  FX Markup:             $0.00

Client Markup:
  Fixed Fee:             $5.00
  Percentage Fee:        $0.00
  FX Markup:             $0.00
─────────────────────────────────
Total Fees:              $25.00
Recipient Receives:      $975.00 USD

Note: Fee configuration varies by client. Contact your account manager for specific fee details.


Payout Status

StatusDescription
pendingPayout initiated, awaiting processing
processingPayout is being routed to the banking partner
completedSuccessfully sent to recipient's bank account
failedPayout failed (see error details)
returnedFunds were returned after being sent

Status Flow

graph LR
    A[pending] --> B[processing]
    B --> C[completed]
    A --> D[failed]
    B --> D
    C --> E[returned]

Payout Lifecycle

Step-by-Step Flow

sequenceDiagram
    participant Your Server
    participant Kira API
    participant Banking Partner
    participant Recipient Bank

    Your Server->>Kira API: POST /payout
    Note over Kira API: Validate request
    Note over Kira API: Check available balance
    Note over Kira API: Calculate fees
    Note over Kira API: Reserve funds
    Kira API-->>Your Server: 201 Created (status: pending)
    Kira API->>Your Server: Webhook: payout.pending

    Note over Kira API: Route to banking partner
    Kira API->>Your Server: Webhook: payout.processing

    Kira API->>Banking Partner: Initiate WIRE/SWIFT transfer
    Banking Partner->>Recipient Bank: Send funds

    Banking Partner-->>Kira API: Transfer completed
    Kira API->>Your Server: Webhook: payout.completed

Timing

StageDuration
Request validationInstant
Balance check & fee calculationInstant
WIRE/SWIFT processing1-3 business days

Total time: Typically 1-3 business days for domestic WIRE, 3-5 business days for international SWIFT.


Webhooks

Receive real-time notifications for payout events.

payout.pending

Sent when the payout is created and awaiting processing.

{
  "event": "payout.pending",
  "data": {
    "payout_id": "po_550e8400-e29b-41d4-a716-446655440010",
    "virtual_account_id": "550e8400-e29b-41d4-a716-446655440003",
    "recipient_id": "550e8400-e29b-41d4-a716-446655440020",
    "amount": "1000.00",
    "currency": "USD",
    "fees": {
      "total_fees": 25
    },
    "recipient_amount": "975.00",
    "created_at": "2024-01-15T14:30:00Z"
  }
}

payout.processing

Sent when the payout is being routed for payment.

{
  "event": "payout.processing",
  "data": {
    "payout_id": "po_550e8400-e29b-41d4-a716-446655440010",
    "virtual_account_id": "550e8400-e29b-41d4-a716-446655440003",
    "status": "processing",
    "created_at": "2024-01-15T14:35:00Z"
  }
}

payout.completed

Sent when the payout has been successfully sent to the recipient.

{
  "event": "payout.completed",
  "data": {
    "payout_id": "po_550e8400-e29b-41d4-a716-446655440010",
    "virtual_account_id": "550e8400-e29b-41d4-a716-446655440003",
    "recipient_id": "550e8400-e29b-41d4-a716-446655440020",
    "amount": "1000.00",
    "currency": "USD",
    "fees": {
      "total_fees": 25
    },
    "recipient_amount": "975.00",
    "recipient_currency": "USD",
    "completed_at": "2024-01-17T10:15:00Z"
  }
}

payout.failed

Sent when a payout fails.

{
  "event": "payout.failed",
  "data": {
    "payout_id": "po_550e8400-e29b-41d4-a716-446655440010",
    "virtual_account_id": "550e8400-e29b-41d4-a716-446655440003",
    "amount": "1000.00",
    "currency": "USD",
    "reason": "compliance_rejected",
    "message": "Transaction did not pass compliance review",
    "created_at": "2024-01-15T14:35:00Z"
  }
}

payout.returned

Sent when funds are returned after being sent (e.g., recipient bank rejected the transfer).

{
  "event": "payout.returned",
  "data": {
    "payout_id": "po_550e8400-e29b-41d4-a716-446655440010",
    "virtual_account_id": "550e8400-e29b-41d4-a716-446655440003",
    "amount": "1000.00",
    "currency": "USD",
    "reason": "recipient_account_closed",
    "message": "Recipient bank account has been closed",
    "returned_at": "2024-01-20T09:00:00Z"
  }
}

Failure Reasons

ReasonDescription
compliance_rejectedTransaction did not pass compliance review
insufficient_balanceAccount balance became insufficient
invalid_recipientRecipient account details are invalid
recipient_bank_rejectedRecipient's bank rejected the transfer
recipient_account_closedRecipient's bank account is closed
compliance_holdTransaction held for manual compliance review

Error Responses

Insufficient Balance

Status: 400 Bad Request

{
  "code": "validation_error",
  "message": "Insufficient balance. Available: 500.00 USD, Requested: 1000.00 USD (plus 25.00 USD in fees)"
}

Invalid Recipient

Status: 400 Bad Request

{
  "code": "validation_error",
  "message": "Recipient not found or is not of type WIRE or SWIFT"
}

Payout on Crypto Mode Account

Status: 400 Bad Request

{
  "code": "validation_error",
  "message": "Cannot initiate payout from crypto mode virtual account. Deposits are automatically converted and sent to the configured wallet."
}

Payout on MX_SPEI Account

Status: 400 Bad Request

{
  "code": "validation_error",
  "message": "Payouts are only supported for US_BANK fiat mode accounts"
}

Virtual Account Not Active

Status: 400 Bad Request

{
  "code": "validation_error",
  "message": "Virtual account is not active. Current status: deactivated"
}

Idempotency Key Conflict

Status: 400 Bad Request

{
  "code": "validation_error",
  "message": "Idempotency key has already been used with different request data"
}

Virtual Account Not Found

Status: 404 Not Found

{
  "code": "not_found",
  "message": "Virtual account not found: 550e8400-e29b-41d4-a716-446655440003"
}

Idempotency

Use the idempotency-key header to safely retry payout requests:

# First request - creates payout
curl -X POST https://api.balampay.com/v1/virtual-accounts/{id}/payout \
  -H "idempotency-key: unique-key-123" \
  -d '{...}'

# Second request with same key and body - returns existing payout
curl -X POST https://api.balampay.com/v1/virtual-accounts/{id}/payout \
  -H "idempotency-key: unique-key-123" \
  -d '{...}'

Important: If you retry with the same idempotency key but different request data, you will receive a 400 error.


Common Scenarios

Scenario 1: Preview Fees Before Payout

// Step 1: Preview to get fees
const previewResponse = await fetch(
  `https://api.balampay.com/v1/virtual-accounts/${virtualAccountId}/payout/preview`,
  {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${token}`,
      'x-api-key': apiKey,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      amount: '1000.00',
      recipient_id: recipientId
    })
  }
);

const preview = await previewResponse.json();

// Display fees to user
console.log(`Amount: $${preview.amount}`);
console.log(`Total Fees: $${preview.fees.total_fees}`);
console.log(`Recipient will receive: $${preview.recipient_amount}`);

// Step 2: Get balance and verify
const balanceResponse = await fetch(
  `https://api.balampay.com/v1/virtual-accounts/${virtualAccountId}/balance`,
  {
    headers: {
      'Authorization': `Bearer ${token}`,
      'x-api-key': apiKey
    }
  }
);

const { available_balance } = await balanceResponse.json();
const totalRequired = parseFloat(preview.amount) + preview.fees.total_fees;

if (parseFloat(available_balance) >= totalRequired) {
  // Step 3: Execute payout
  const payoutResponse = await fetch(
    `https://api.balampay.com/v1/virtual-accounts/${virtualAccountId}/payout`,
    {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${token}`,
        'x-api-key': apiKey,
        'Content-Type': 'application/json',
        'idempotency-key': crypto.randomUUID()
      },
      body: JSON.stringify({
        amount: preview.amount,
        recipient_id: recipientId
      })
    }
  );

  const payout = await payoutResponse.json();
  console.log(`Payout initiated: ${payout.id}`);
} else {
  console.log(`Insufficient balance. Available: ${available_balance}, Required: ${totalRequired}`);
}

Scenario 2: Display Fee Breakdown to User

// Use preview to show fees before confirmation
const preview = await previewResponse.json();

const { fees } = preview;

console.log(`
  Payout Amount:         $${preview.amount}

  Base Fees:
    Fixed Fee:           $${fees.base_fees.fixed_fee}
    Percentage Fee:      ${fees.base_fees.percentage_fee}%
    FX Markup:           ${fees.base_fees.fx_markup}%

  Client Markup:
    Fixed Fee:           $${fees.client_markup.fixed_fee}
    Percentage Fee:      ${fees.client_markup.percentage_fee}%
    FX Markup:           ${fees.client_markup.fx_markup}%
  ─────────────────────────────────
  Total Fees:            $${fees.total_fees}
  Recipient Receives:    $${preview.recipient_amount}
`);

Scenario 3: Handle Webhook Notifications

app.post('/webhooks/kira', (req, res) => {
  const event = req.body;

  switch (event.event) {
    case 'payout.pending':
      console.log(`Payout ${event.data.payout_id} created`);
      // Update your records, notify user
      break;

    case 'payout.processing':
      console.log(`Payout ${event.data.payout_id} is processing`);
      break;

    case 'payout.completed':
      console.log(`Payout ${event.data.payout_id} completed`);
      console.log(`Recipient received: ${event.data.recipient_amount}`);
      // Update your records, notify user of success
      break;

    case 'payout.failed':
      console.log(`Payout ${event.data.payout_id} failed`);
      console.log(`Reason: ${event.data.reason}`);
      // Handle failure, refund balance, notify user
      break;

    case 'payout.returned':
      console.log(`Payout ${event.data.payout_id} was returned`);
      console.log(`Reason: ${event.data.reason}`);
      // Handle return, credit balance back, notify user
      break;
  }

  res.status(200).send('OK');
});

Best Practices

  1. Always use preview first - Call /payout/preview to show fees before confirmation
  2. Create recipients in advance - Set up WIRE/SWIFT recipients before initiating payouts
  3. Check balance after preview - Verify available balance covers amount plus total_fees
  4. Use unique idempotency keys - Generate a new UUID for each payout request
  5. Display fee breakdown - Show all fee components to users for transparency
  6. Listen to webhooks - Don't poll for status; use webhooks for real-time updates
  7. Store payout IDs - Keep track of payout IDs for reconciliation
  8. Handle returns gracefully - Implement logic to credit balance back on returns
  9. Validate recipient type - Ensure recipient is WIRE or SWIFT before attempting payout

Troubleshooting

Payout stuck in pending

Possible causes:

  1. High volume of transactions being processed
  2. Banking partner delays

Solutions:

  • Wait for webhook notification
  • Contact support if pending for more than 24 hours

Payout failed

Possible causes:

  1. Compliance rejection
  2. Invalid recipient details
  3. Recipient bank issues

Solutions:

  • Review the failure reason in the webhook
  • Verify recipient bank details are correct
  • Contact support with payout ID

Funds returned after completion

Possible causes:

  1. Recipient account closed
  2. Recipient bank rejected the transfer
  3. Incorrect account details

Solutions:

  • Funds are automatically credited back to virtual account balance
  • Verify recipient details and create a new payout
  • Contact recipient to confirm their bank account status

Next Steps

  1. Create recipients for WIRE/SWIFT transfers
  2. Set up webhooks for real-time payout notifications
  3. Check balance before initiating payouts
  4. Build user-facing payout history views

Support