Skip to content
@pulgueta/wompi
Esc
navigateopen⌘Jpreview
On this page

Payout batch

Disperse a payroll batch end to end — pick an account, resolve the bank, create the batch, poll it and reconcile each transaction.

This is a complete dispersion flow you can run as-is against the sandbox and later point at production. It tops up the sandbox account, creates a one-item batch, polls until Wompi settles it, and prints the per-transaction outcome.

Environment

WOMPI_PAYOUTS_API_KEY=...
WOMPI_PAYOUTS_USER_PRINCIPAL_ID=...
WOMPI_PAYOUTS_EVENTS_KEY=...

These credentials come from Desarrollo → Programadores → Pagos a Terceros in the dashboard; switch the dashboard to sandbox mode for the sandbox values.

The request below sets personType explicitly. Wompi’s OpenAPI contract includes it while Wompi’s prose example omits it, so the SDK keeps it optional and doesn’t infer a value.

The flow

import { WompiPayoutsClient } from "@pulgueta/wompi";

const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

const payouts = new WompiPayoutsClient({
  apiKey: process.env.WOMPI_PAYOUTS_API_KEY!,
  userPrincipalId: process.env.WOMPI_PAYOUTS_USER_PRINCIPAL_ID!,
  sandbox: true, // flip to false (or omit) for production
});

// 1. Origin account — its id is the batch's accountId.
const [accountsError, accounts] = await payouts.listAccounts({
  status: "ACTIVE",
});
if (accountsError) throw accountsError;
const account = accounts[0];
if (!account) {
  throw new Error(
    "No active payout account. Check that these Pagos a Terceros credentials belong to an onboarded merchant — sandbox accounts are auto-provisioned with the sandbox keys.",
  );
}
console.log(
  `Origin account ${account.number}: $${(account.balanceInCents ?? 0) / 100} COP`,
);

// 2. Sandbox only: top the account up so the batch clears.
const [rechargeError] = await payouts.rechargeAccountBalance({
  accountId: account.id,
  amountInCents: 10_000_000, // $100,000.00 COP
});
if (rechargeError) throw rechargeError;

// 3. Destination bank — its id is each transaction's bankId.
const [banksError, banks] = await payouts.listBanks();
if (banksError) throw banksError;
const bank = banks.find((b) => b.code === "BANCOLOMBIA") ?? banks[0];
if (!bank) {
  throw new Error(
    "The banks catalog came back empty — the Payouts API returned no dispersion destinations for this merchant.",
  );
}

// 4. Create the batch. Wompi rejects reuse of the idempotency key for 24 hours.
//    Persist the returned payout id and reference before retrying later.
const reference = `payroll-${Date.now()}`;
const [createError, created] = await payouts.createPayout(
  {
    reference,
    accountId: account.id,
    paymentType: "PAYROLL",
    transactionStatus: "APPROVED", // sandbox only: force the outcome
    transactions: [
      {
        personType: "NATURAL",
        legalIdType: "CC",
        legalId: "1000000000",
        bankId: bank.id,
        accountType: "AHORROS",
        accountNumber: "12345678",
        name: "John Doe",
        email: "john@example.com",
        amount: 1_500_000, // $15,000.00 COP, in cents
        reference: `${reference}-jdoe`,
      },
    ],
  },
  { idempotencyKey: reference },
);
if (createError) throw createError;
console.log(
  `Batch ${created.payoutId}: ${created.transactions} transaction(s) queued`,
);

// 5. Poll until Wompi reaches a terminal batch state.
const isTerminal = (status: string) =>
  ["TOTAL_PAYMENT", "REJECTED"].includes(status);
let payout: Awaited<ReturnType<typeof payouts.getPayout>>[1] = null;
for (let attempt = 0; attempt < 20; attempt++) {
  const [error, fetched] = await payouts.getPayout(created.payoutId);
  if (error) throw error;
  payout = fetched;
  if (isTerminal(payout.status)) break;
  await sleep(2_000);
}
if (!payout || !isTerminal(payout.status)) {
  throw new Error(`Batch did not settle: ${payout?.status ?? "UNKNOWN"}`);
}
console.log(`Batch settled as ${payout.status}`);

// 6. Reconcile: read every transaction's final state.
const [txError, txPage] = await payouts.listPayoutTransactions(
  created.payoutId,
);
if (txError) throw txError;
for (const tx of txPage.records) {
  console.log(`  ${tx.reference}: ${tx.status}`, tx.failureReason ?? "");
}

The 24-hour idempotency window isn’t a durable record. In production, persist created.payoutId and reference before acknowledging the operation.

Run it with your runtime of choice:

node --env-file=.env.local scripts/payout-batch.ts

Listening for the result

Polling works for a script; production systems should react to the payout.updated and transaction.updated webhooks instead. Configure the events URL in the dashboard and verify each delivery. Wompi retries non-2xx responses up to three times, so persist a delivery key and process duplicate events idempotently before applying side effects:

import type { APIRoute } from "astro";
import {
  isPayoutTransactionUpdatedEvent,
  isPayoutUpdatedEvent,
  verifyPayoutEvent,
} from "@pulgueta/wompi/server";

export const prerender = false;

export const POST: APIRoute = async ({ request }) => {
  const [error, event] = await verifyPayoutEvent(await request.text(), {
    eventsKey: process.env.WOMPI_PAYOUTS_EVENTS_KEY!,
  });

  if (error) return new Response("Invalid signature", { status: 403 });

  if (isPayoutUpdatedEvent(event)) {
    // Persist every transition. For bank batches Wompi documents only
    // TOTAL_PAYMENT and REJECTED as final; PARTIAL_PAYMENT and NOT_APPROVED
    // can still advance.
    console.log("batch", event.data.payout.id, "→", event.data.payout.status);
  }

  if (isPayoutTransactionUpdatedEvent(event)) {
    // Reconcile a single payee, e.g. flag FAILED ones for retry.
    const tx = event.data.transaction;
    console.log("transaction", tx.id, "→", tx.status, tx.failureReason ?? "");
  }

  return new Response("OK");
};

Was this page helpful?