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
Statusfilter 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 with409 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.