Payouts Quickstart

Send your first payout in sandbox: create a batch, onboard a test payee, create and submit a payment, and watch it complete.

This walkthrough sends one 25.00 USD payout to a test payee in sandbox. Run the steps in order; each one uses a value from the step before. It takes about 20 minutes, most of it waiting for the payout to complete.

Before you start

  • Sandbox API credentials with the payout Administrator or Expert User role. Submitting a payment needs the Payment Approval permission. See Permissions.
  • A sandbox ledger account with a balance of at least 30.00 in its currency. A ledger account is your balance with Payment Labs; every payment is paid from one. If your sandbox balance is zero, ask your Payment Labs contact to fund it.
  • A browser, to complete the payee's onboarding.

The samples use these variables:

export BASE_URL="https://api-sandbox.paymentlabs.io"
const BASE_URL = "https://api-sandbox.paymentlabs.io";

async function api<T>(token: string, method: string, path: string, body?: unknown): Promise<T> {
  const response = await fetch(`${BASE_URL}${path}`, {
    method,
    headers: {
      Authorization: `Bearer ${token}`,
      ...(body !== undefined && { "Content-Type": "application/json" }),
    },
    body: body !== undefined ? JSON.stringify(body) : undefined,
  });
  const text = await response.text();
  if (!response.ok) {
    throw new Error(`${method} ${path} failed with ${response.status}: ${text}`);
  }
  return (text ? JSON.parse(text) : undefined) as T;
}

Step 1: Get a token

Call Create Access Token with your client ID and secret.

export TOKEN=$(curl -s -X POST "$BASE_URL/login" \
  -H "Content-Type: application/json" \
  -d '{"method": "client-secret", "key": "<client-id>", "secret": "<secret>"}' | jq -r .token)
const { token } = await fetch(`${BASE_URL}/login`, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ method: "client-secret", key: process.env.PL_CLIENT_ID, secret: process.env.PL_SECRET }),
}).then((r) => r.json() as Promise<{ token: string }>);

The token is valid for 3 hours. See Environments and Authentication.

Step 2: Find your ledger account and tax jurisdictions

Get Payor returns your account, including your ledger accounts and the tax jurisdictions enabled for you.

curl -s "$BASE_URL/payors/me" -H "Authorization: Bearer $TOKEN" \
  | jq '{taxJurisdictions, ledgerAccounts: [.ledgerAccounts[] | {id, alias, currency: .currency.code, balance: .balance.formattedValue}]}'
type Payor = {
  id: string;
  taxJurisdictions?: string[];
  ledgerAccounts: { id: string; alias: string; balance: { value: number; currency: string } }[];
};
const payor = await api<Payor>(token, "GET", "/payors/me");
const ledgerAccount = payor.ledgerAccounts.find((account) => account.balance.currency === "USD" && account.balance.value >= 30);
if (!ledgerAccount) throw new Error("No funded USD ledger account in sandbox");

Copy the id of a funded USD ledger account into a variable:

export LEDGER_ACCOUNT_ID="<ledger-account-id>"

Note:

  • ledgerAccounts[].id: you send it as ledgerAccount when you create the payment.
  • taxJurisdictions: if it lists more than one jurisdiction, you must send taxJurisdiction when you create the batch. With zero or one, Payment Labs picks the default for you. See the Tax Guide.

Step 3: Create a batch

A batch groups payments and sets their defaults. Create it with Create Batch.

export BATCH_ID=$(curl -s -X POST "$BASE_URL/batches" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Quickstart payouts",
    "referenceId": "quickstart-2026-10",
    "defaultPaymentCategory": "remote-contractors"
  }' | jq -r .id)
const batch = await api<{ id: string }>(token, "POST", "/batches", {
  name: "Quickstart payouts",
  referenceId: "quickstart-2026-10",
  defaultPaymentCategory: "remote-contractors",
  // taxJurisdiction: "us", // required only if your account has more than one jurisdiction
});

The response is 201 with the batch. Keep id (for example B-01JBX7R2M4Q8T6N3K5W9Y1Z0AC).

Step 4: Onboard a test payee

A payee must be payable (identity verified, tax form completed for the payment's jurisdiction, payment method added) before you can submit a payment to them. In sandbox you complete onboarding yourself through the payee widget.

Get Widget URL creates the payee if it does not exist and returns a signed onboarding URL. Use an email you control, and a reference ID that identifies the payee in your system.

curl -s -X PUT "$BASE_URL/payors/widget-url" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "payeeEmail": "[email protected]",
    "payeeReferenceId": "contractor-1042"
  }'
const { url } = await api<{ url: string }>(token, "PUT", "/payors/widget-url", {
  payeeEmail: "[email protected]",
  payeeReferenceId: "contractor-1042",
});
console.log(`Open this URL to onboard the payee: ${url}`);

Open url in a browser and complete every step:

  1. Enter the payee's personal details. Identity verification is simulated in sandbox and passes.
  2. Complete the tax form, if your account has a tax jurisdiction enabled.
  3. Add a bank account as the payment method.

To embed this flow in your own site instead, see Payee Widget.

Step 5: Create a payment

Create Payment creates the payment in DRAFT. Send the same payeeEmail and payeeReferenceId as in step 4 so the payment goes to that payee. The payeeMemo value completed tells sandbox to complete the bank transfer; see Bank Transfer Payout Test Values.

export PAYMENT_ID=$(curl -s -X POST "$BASE_URL/batches/$BATCH_ID/payments" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "payeeEmail": "[email protected]",
    "payeeReferenceId": "contractor-1042",
    "referenceId": "invoice-7781",
    "sourceAmount": "25.00",
    "ledgerAccount": "'"$LEDGER_ACCOUNT_ID"'",
    "payeeMemo": "Quickstart completed"
  }' | jq -r .id)
type Payment = { id: string; batchId: string; status: string; isSubmittable: boolean; payee: { payable: boolean } };
const payment = await api<Payment>(token, "POST", `/batches/${batch.id}/payments`, {
  payeeEmail: "[email protected]",
  payeeReferenceId: "contractor-1042",
  referenceId: "invoice-7781",
  sourceAmount: "25.00",
  ledgerAccount: ledgerAccount.id,
  payeeMemo: "Quickstart completed",
});

The response is 201 with the payment: status is DRAFT, and isSubmittable is true once the payee is payable. Keep id (for example P-01JBX7S9QK3M8T2N6R4W0Y5ZDE).

  • sourceAmount is a decimal string in the ledger account's currency.
  • The payee receives an email telling them about the payment.
  • If isSubmittable is false, the payee has not finished onboarding. Finish step 4, then read the payment again with Get Payment.

Step 6: Preview fees (optional)

Submit Payment Dry Run returns what the payment will cost you, what the payee will receive and any tax withheld, without changing anything.

curl -s -X POST "$BASE_URL/batches/$BATCH_ID/payments/$PAYMENT_ID/submit/dry-run" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}' | jq '{payorTotal: .payor.totalAmount.formattedValue, payeeReceives: .payee.totalDestinationAmount.formattedValue}'

Step 7: Submit the payment

Submit Payment queues the payment for payout and returns 202 with no body. Your ledger account is charged when the payout is created.

curl -s -o /dev/null -w "%{http_code}\n" -X POST "$BASE_URL/batches/$BATCH_ID/payments/$PAYMENT_ID/submit" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
await api<void>(token, "POST", `/batches/${batch.id}/payments/${payment.id}/submit`, {});
⚠️

Submitting moves money. After the payout is sent you cannot cancel it. In production, check the amount and payee before you submit.

If the payee is not payable yet, submit with {"autoSubmit": true} instead: the payment waits in WAITING_PAYABILITY and Payment Labs submits it as soon as the payee finishes onboarding.

ErrorMeaningWhat to do
409The payment was already submitted, or the payee is not payable.Read the payment. If status is past DRAFT, it is already on its way.
423 INSUFFICIENT_BALANCEThe ledger account balance is lower than the payment.Fund the ledger account, then submit again.
423 NOT_EDITABLEThe batch is archived.Unarchive the batch or use another one.
403 FORBIDDEN_INSUFFICIENT_TAGSYour credential cannot approve payments.Use a credential with the Administrator or Expert User role.

Step 8: Follow the payment to COMPLETED

Read the payment with Get Payment until it reaches a final status.

curl -s "$BASE_URL/batches/$BATCH_ID/payments/$PAYMENT_ID" -H "Authorization: Bearer $TOKEN" | jq -r .status
const finalStatuses = ["COMPLETED", "RETURNED", "FAILED", "CANCELLED", "EXPIRED"];
let current: Payment;
do {
  await new Promise((resolve) => setTimeout(resolve, 60_000));
  current = await api<Payment>(token, "GET", `/batches/${batch.id}/payments/${payment.id}`);
  console.log(current.status);
} while (!finalStatuses.includes(current.status));

In sandbox the payment moves SUBMITTED → PROCESSING → COMPLETED, about 15 minutes after it reaches PROCESSING.

In production, do not poll. Register a webhook for payment-status-changed and Payment Labs posts the payment to your endpoint at each status change. See Webhooks.

Test the other outcomes

Repeat steps 5 to 8 with another payeeMemo keyword to see a payout that is returned (returned) or stays in flight (processed). See Bank Transfer Payout Test Values.

Next steps


What’s Next

Did this page help you?