Skip to content

payouts.rails

A payout rail is delivery configuration: which file format is rendered, which of your columns carry which values, and whether settlement debits the brand wallet. Nothing exports until one exists — there is no auto-provisioning.

const rail = await boomin.payouts.rails.create({
rail: "csv_batch",
config: {
format: "paypal_payouts_csv",
columns: [
{ key: "email", header: "Email" },
{ key: "amount", header: "Amount" },
],
},
isDefault: true,
});
MethodRouteScope
create(params, options)POST /payouts/railspayout_rails:write
list(params, options)GET /payouts/railspayout_rails:read
retrieve(id, options)GET /payouts/rails/{id}payout_rails:read
update(id, params, options)POST /payouts/rails/{id}payout_rails:write

payout_rails:* is deliberately separate from payouts:*. A column mapping decides where money lands, which is banking configuration rather than payout execution — see why.

{
"id": "prail_db003d33-…",
"object": "payout_rail",
"rail": "csv_batch",
"status": "active",
"is_default": true,
"config": {
"format": "paypal_payouts_csv",
"wallet_funded": false,
"columns": [
{ "key": "email", "header": "Email" },
{ "key": "amount", "header": "Amount" }
]
},
"livemode": true,
"created_at": "2026-08-03T06:12:08.755Z",
"updated_at": "2026-08-03T06:12:08.755Z"
}

Ids are prefixed prail_. The SDK reads this back as rail.isDefault, rail.config.walletFunded, rail.config.format — and rail.config.columns exactly as you sent it.

railStatus
csv_batchAvailable. Renders a file, you pay out of band, you confirm.
stripe_connectConfigurable, cannot pay anyone yet — see below.
await boomin.payouts.rails.create({
rail: "csv_batch",
config: {
format: "paypal_payouts_csv", // REQUIRED
walletFunded: false,
columns: [
{ key: "email", header: "Email" },
{ key: "amount", header: "Amount" },
{ key: "currency", header: "Currency" },
{ key: "reference", header: "REF" },
],
},
isDefault: true,
});

config.format is required and has no default:

formatFile
paypal_payouts_csvPayPal Payouts batch CSV
wise_batch_csvWise batch-transfer CSV

Omitting it is a typed 400: “config.format is required for a csv_batch rail — the PayPal and Wise column sets differ, so there is no neutral default.” The API refuses to pick, because picking is choosing the file your bank reads.

walletFunded: true turns batches.confirm into a guarded brand-wallet debit per item. Leave it false — the default — when money moves outside Boomin, which is the normal csv_batch case.

The API accepts a create call for this rail. It cannot disburse today. Entity payouts over Connect need a transfers-only Express capability that is not yet approved on Boomin’s Stripe platform account, so entities have no onboarding path to complete and there is no disburse route on the Platform API — deliberately absent rather than present and always failing.

config must be empty for stripe_connect; every csv key is refused rather than accepted-and-ignored:

await boomin.payouts.rails.create({ rail: "stripe_connect" });
// config.format / .columns / .walletFunded → invalid_request (400)

Configure csv_batch and plan around it.

The SDK converts camelCase to the wire’s snake_case at the boundary. Inside columns it converts nothing — no key, no value, at any depth. Headers come back byte-for-byte, in the order you sent them.

await boomin.payouts.rails.create({
rail: "csv_batch",
config: {
format: "wise_batch_csv",
walletFunded: false, // → wallet_funded
columns: [
{ key: "email", header: "Email Address" }, // ← untouched
{ key: "amount", header: "payoutAmount" }, // ← untouched
{ key: "currency", header: "Currency_Code" }, // ← untouched
{ key: "reference", header: "REF" }, // ← untouched
],
},
});

The rendered file’s header row is then exactly:

Email Address,payoutAmount,Currency_Code,REF

payoutAmount staying payoutAmount is the whole point. A boundary that re-cased it to payout_amount would change which column your bank reads, and nothing would report an error — on this surface a rename is a money bug, not a cosmetic one.

header is yours; key is not. It selects which value of a payout row fills the column, and is validated server-side:

keyValue
emailRecipient email — the handle a csv_batch batch requires
nameRecipient name
amountAmount in major units (25.00)
amount_centsAmount in minor units (2500)
currencyISO currency code
referenceThe batch item’s reference
noteFree-text note column

An unknown key would render as undefined in whatever column it named, so it is a 400. Between 1 and 50 columns.

A second create for a rail kind this brand has already configured throws:

import { PayoutRailAlreadyExistsError } from "@boomin/sdk";
try {
await boomin.payouts.rails.create({ rail: "csv_batch", config: { format: "wise_batch_csv" } });
} catch (err) {
if (err instanceof PayoutRailAlreadyExistsError) {
// find it and update it explicitly
const { data } = await boomin.payouts.rails.list();
const existing = data.find((r) => r.rail === "csv_batch");
await boomin.payouts.rails.update(existing.id, { config: { format: "wise_batch_csv" } });
} else throw err;
}

Code payout_rail_already_exists (409). A brand has at most one rail per kind, and an upsert here would mean a call reading as “add a rail” silently rewrote where money lands. You have to say update out loud.

await boomin.payouts.rails.update("prail_...", { status: "disabled" });
await boomin.payouts.rails.update("prail_...", { isDefault: true });
await boomin.payouts.rails.update("prail_...", {
config: {
format: "wise_batch_csv",
columns: [{ key: "email", header: "Recipient" }, { key: "amount", header: "Amount" }],
},
});
ParamValues
configReplaces the stored object wholesale
isDefaulttrue claims the default; false releases it
statusactive | disabled

rail cannot be changed — a rail kind is what the object is.

An omitted rail on batches.create resolves to the brand’s default. At most one active default per brand is enforced by a partial unique index in the database, so that resolution can never be ambiguous.

Setting isDefault: true on a second rail atomically clears the first — both statements ship in one transaction, and two callers racing to become default both succeed with the database ending on exactly one default.

A default the batch builder cannot use is treated as no default at all: payout_rail_required.

const { data } = await boomin.payouts.rails.list();
const rail = await boomin.payouts.rails.retrieve("prail_...");

list accepts limit (1–100, default 20) and startingAfter. Rails are few, so paging is honoured in memory rather than pushed into SQL — but it is honoured.

Rail config appears here and not on payouts.connectStatus(), which is a payouts:read surface and reports identity and state only.

CodeHTTPWhen
invalid_request400Missing config.format on csv_batch, an unknown columns[].key, or a csv key on stripe_connect. param names it.
payout_rail_already_exists409create for a kind already configured. → PayoutRailAlreadyExistsError
payout_rail_required409Raised by batch builds and exports — no active rail of the requested kind. → PayoutRailRequiredError
payout_rail_not_found404Unknown, malformed, or another tenant’s rail id.
Terminal window
npx @boomin/cli payout rails create --rail csv_batch \
--format paypal_payouts_csv --default \
--columns '[{"key":"email","header":"Email"},{"key":"amount","header":"Amount"}]'
npx @boomin/cli payout rails list
npx @boomin/cli payout rails show prail_...
npx @boomin/cli payout rails update prail_... --status disabled

--columns takes JSON and is handed to the API untouched, for the same reason the SDK treats it as opaque. Full flag table: CLI reference.