Payouts

A payout moves funds from your Plugipay balance to a configured bank account. Two methods exist: manual (you record the transfer, mark its lifecycle by hand — suited for offline / in-person reconciliation) and xendit_disbursement (Plugipay drives the provider to settle it). The Go SDK exposes the nine payout methods (plus balance + bank-account helpers) behind client.Payouts. For wire shapes, status lifecycle, and provider rules, see API → Payouts.

Field on the Client

client.Payouts — type *plugipay.PayoutsResource. Installed by NewClient; shares the parent *http.Client, base URL, key, and OnBehalfOf default. Safe for concurrent use.

Methods

Create

Signature. func (r *PayoutsResource) Create(ctx context.Context, in PayoutCreateInput) (*Payout, error)

Initiates a payout. Amount + Currency are required; bank fields are optional — when omitted the payout uses the workspace's saved bank account (set via UpdateBankAccount). Auto-keyed for idempotency.

ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

p, err := client.Payouts.Create(ctx, plugipay.PayoutCreateInput{
    Amount:   500_000_00, // IDR 500,000
    Currency: plugipay.CurrencyIDR,
})
if err != nil {
    return err
}
log.Printf("payout %s status=%s method=%s", p.ID, p.Status, p.Method)

Override the bank account inline (e.g. one-off payout to a contractor):

holder := "PT Acme Indonesia"
acctNo := "1234567890"
bank := "BCA"

p, err := client.Payouts.Create(ctx, plugipay.PayoutCreateInput{
    Amount:            250_000_00,
    Currency:          plugipay.CurrencyIDR,
    BankName:          &bank,
    BankAccountNumber: &acctNo,
    BankAccountHolder: &holder,
})

Get / List / Cancel

p, err  := client.Payouts.Get(ctx, "po_01HX...")           // (*Payout, error)
pg, err := client.Payouts.List(ctx, plugipay.PayoutListParams{
    Status: ptrPayoutStatus(plugipay.PayoutStatusPending),
})                                                          // (Page[Payout], error)
p, err  := client.Payouts.Cancel(ctx, "po_01HX...")         // (*Payout, error)
  • List is cursor-paginated. The Status filter is *PayoutStatus — use the typed constants (PayoutStatusPending, PayoutStatusInTransit, PayoutStatusPaid, PayoutStatusFailed, PayoutStatusCancelled).
  • Cancel is auto-keyed for idempotency; only valid while Status == "pending". Terminal statuses reject with 409 conflict.

Manual transitions

For method == "manual" payouts, three explicit transitions step the lifecycle forward:

// You initiated the bank transfer; mark it in flight:
ref := "TRX-2026-05-13-001"
p, err := client.Payouts.MarkInTransit(ctx, "po_01HX...", &ref)

// The transfer landed:
p, err := client.Payouts.MarkPaid(ctx, "po_01HX...", &ref)

// Provider/bank rejected it:
p, err := client.Payouts.MarkFailed(ctx, "po_01HX...", "insufficient funds at source")

All three are auto-keyed for idempotency. They only apply to manual payouts — xendit_disbursement advances on its own as the provider sends webhooks. Calling them on a Xendit payout returns 409 conflict.

Balance

Signature. func (r *PayoutsResource) Balance(ctx context.Context) (*AvailableBalance, error)

Returns the workspace's split balance — total ledger balance, locked (in-flight refunds, in-flight payouts), and the amount you're free to pay out right now.

bal, err := client.Payouts.Balance(ctx)
if err != nil { return err }
log.Printf("ledger=%d locked=%d available=%d",
    bal.LedgerBalance, bal.Locked, bal.Available)

Use bal.Available as the upper bound when prompting for a payout amount in your UI.

GetBankAccount / UpdateBankAccount

ba, err := client.Payouts.GetBankAccount(ctx) // (*BankAccount, error)
ba, err := client.Payouts.UpdateBankAccount(ctx, plugipay.BankAccountInput{
    BankName:          "BCA",
    BankAccountNumber: "1234567890",
    BankAccountHolder: "PT Acme Indonesia",
}) // (*BankAccount, error)

GetBankAccount returns the current saved configuration with a Configured bool so you can render a "set up first" state without inspecting individual fields. UpdateBankAccount is a PATCH — the SDK currently sends all required fields, so pass them all on every call.

Types

type Payout struct {
    ID                  string       `json:"id"`         // "po_..."
    AccountID           string       `json:"accountId"`
    Amount              int64        `json:"amount"`
    Currency            string       `json:"currency"`
    Status              PayoutStatus `json:"status"`     // pending|in_transit|paid|failed|cancelled
    Method              PayoutMethod `json:"method"`     // manual | xendit_disbursement
    BankCode            *string      `json:"bankCode"`
    BankName            string       `json:"bankName"`
    BankAccountNumber   string       `json:"bankAccountNumber"`
    BankAccountHolder   string       `json:"bankAccountHolder"`
    Note                *string      `json:"note"`
    Reference           *string      `json:"reference"`
    FailureReason       *string      `json:"failureReason"`
    LedgerTransactionID *string      `json:"ledgerTransactionId"`
    ProcessedAt         *string      `json:"processedAt"`
    CompletedAt         *string      `json:"completedAt"`
    CreatedAt           string       `json:"createdAt"`
    UpdatedAt           string       `json:"updatedAt"`
}

type AvailableBalance struct {
    LedgerBalance int64   `json:"ledgerBalance"`
    Locked        int64   `json:"locked"`
    Available     int64   `json:"available"`
    Currency      *string `json:"currency"`
}

type BankAccount struct {
    BankCode          *string `json:"bankCode"`
    BankName          *string `json:"bankName"`
    BankAccountNumber *string `json:"bankAccountNumber"`
    BankAccountHolder *string `json:"bankAccountHolder"`
    Configured        bool    `json:"configured"`
}

Field-level rules: API → Payouts.

Common patterns

"Pay out everything available"

func sweepAvailable(ctx context.Context, c *plugipay.Client) (*plugipay.Payout, error) {
    bal, err := c.Payouts.Balance(ctx)
    if err != nil { return nil, err }
    if bal.Available <= 0 {
        return nil, nil
    }
    return c.Payouts.Create(ctx, plugipay.PayoutCreateInput{
        Amount:   bal.Available,
        Currency: plugipay.CurrencyIDR,
    })
}

Pair with cron — e.g. daily at 09:00 local time — for an unattended sweep.

Reconcile manual payouts

limit := 100
status := plugipay.PayoutStatusInTransit
var cursor *string
for {
    page, err := c.Payouts.List(ctx, plugipay.PayoutListParams{
        Status: &status, Limit: &limit, Cursor: cursor,
    })
    if err != nil { return err }
    for _, p := range page.Data {
        if landedInBank(p) {
            _, _ = c.Payouts.MarkPaid(ctx, p.ID, p.Reference)
        }
    }
    if !page.HasMore { break }
    cursor = page.Cursor
}

Defensive typed-status pointers

The PayoutStatus filter on List is a typed-string pointer, which requires a small helper to express inline:

func ptrPayoutStatus(s plugipay.PayoutStatus) *plugipay.PayoutStatus { return &s }

page, _ := c.Payouts.List(ctx, plugipay.PayoutListParams{
    Status: ptrPayoutStatus(plugipay.PayoutStatusPaid),
})

Context + retries on Create

Create is auto-keyed for idempotency, so a 5xx retry is safe:

var po *plugipay.Payout
for attempt := 0; attempt < 4; attempt++ {
    po, err = c.Payouts.Create(ctx, in)
    if err == nil { break }
    var pe *plugipay.Error
    if !errors.As(err, &pe) || (pe.Status != 0 && pe.Status < 500 && pe.Status != 429) {
        return err
    }
    time.Sleep(time.Duration(1<<attempt) * 250 * time.Millisecond)
}

Errors

Code Status Cause
validation_error 400 Amount <= 0, missing bank fields, currency mismatch.
insufficient_balance 422 Requested amount exceeds bal.Available.
not_found 404 Payout id missing.
conflict 409 MarkPaid on a Xendit payout, Cancel on a terminal payout.
provider_error 502 Upstream provider rejected the disbursement.
insufficient_scope 403 Key lacks plugipay:payout:write.
var pe *plugipay.Error
if errors.As(err, &pe) && pe.Code == "insufficient_balance" {
    log.Printf("not enough available (available=%d)", knownAvailable)
}

Full mechanics: Errors.

Next

  • Ledger — payouts post matching ledger entries.
  • Webhooks — subscribe to payout.paid / payout.failed.
  • API → Payouts — HTTP-level reference.
Plugipay — Payments that don't tax your success