BRE-B payout
Resolve a BRE-B key, confirm the beneficiary, create the dispersal, and settle it from the webhook.
The full dispersal flow Wompi recommends: resolve the key so your user confirms
the beneficiary, create the payout with an idempotency key derived from your
own business id, and mark it paid when the webhook lands. Run it against the
sandbox first — the same code works in production by swapping credentials and
sandbox: false.
Environment
WOMPI_PAYOUTS_API_KEY=...
WOMPI_PAYOUTS_USER_PRINCIPAL_ID=...
WOMPI_PAYOUTS_ACCOUNT_ID=...
WOMPI_PAYOUTS_EVENTS_KEY=...
The credentials come from Desarrollo → Programadores → Pagos a Terceros in
the dashboard (sandbox mode for sandbox values); WOMPI_PAYOUTS_ACCOUNT_ID is
your funded origin account’s id from listAccounts().
1. Preview the beneficiary
The customer types a key; you resolve it and show the masked holder back for confirmation. No money moves here.
import type { APIRoute } from "astro";
import { WompiPayoutsClient } from "@pulgueta/wompi";
export const prerender = false;
const payouts = new WompiPayoutsClient({
apiKey: process.env.WOMPI_PAYOUTS_API_KEY!,
userPrincipalId: process.env.WOMPI_PAYOUTS_USER_PRINCIPAL_ID!,
sandbox: true,
});
export const POST: APIRoute = async ({ request }) => {
const { keyValue, keyType } = await request.json();
const [error, holder] = await payouts.resolveBrebKey(keyValue, keyType);
if (error) {
// EXC_034 (not registered) and EXC_033 (bad format) both land here.
return Response.json({ error: error.message }, { status: 422 });
}
// Render this and ask the user: "Is this who you want to pay?"
return Response.json({
holderName: holder.holderName, // "Eli*** Can*** Mor***"
bank: holder.financialEntity?.name, // "BANCO DAVIVIENDA"
});
};
In sandbox, @elias123 (type ALPHANUMERIC) resolves successfully;
noexiste@test.com simulates a 404.
2. Create the dispersal
Only after the user confirms. Derive the idempotency key from your own order id
so Wompi rejects duplicate submissions during its 24-hour window. Persist the
resulting payoutId and reconcile it before retrying outside that window.
import type { APIRoute } from "astro";
import { WompiPayoutsClient } from "@pulgueta/wompi";
export const prerender = false;
const payouts = new WompiPayoutsClient({
apiKey: process.env.WOMPI_PAYOUTS_API_KEY!,
userPrincipalId: process.env.WOMPI_PAYOUTS_USER_PRINCIPAL_ID!,
sandbox: true,
});
export const POST: APIRoute = async ({ request }) => {
const { orderId, keyValue, name, email, amountInCents } =
await request.json();
const [error, created] = await payouts.createPayout(
{
reference: `order-${orderId}`,
accountId: process.env.WOMPI_PAYOUTS_ACCOUNT_ID!, // your funded origin account
paymentType: "PROVIDERS",
transactions: [
// email is required on BRE-B transactions.
{ key: keyValue, name, email, amount: amountInCents },
],
},
{ idempotencyKey: `payout-${orderId}` }, // 1-64 chars: letters, numbers, hyphens
);
if (error) return Response.json({ error: error.message }, { status: 422 });
// Persist payoutId with status "PENDING"; the webhook settles it.
return Response.json({ payoutId: created.payoutId });
};
3. Settle from the webhook
Wompi POSTs transaction.updated (per dispersal) and payout.updated (per
batch) to the events URL configured under Pagos a Terceros. Verify each
delivery with verifyPayoutEvent and the payouts events secret — a different
secret from your payments webhooks — then narrow with the payout guards.
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 (isPayoutTransactionUpdatedEvent(event)) {
const tx = event.data.transaction;
if (tx.status === "APPROVED") {
// markPaid(tx.payoutId, tx.id);
} else if (tx.status === "FAILED" || tx.status === "REJECTED") {
// BRE-B events word the failure as { code, description }.
const reason =
typeof tx.failureReason === "string"
? tx.failureReason
: tx.failureReason?.description;
// markFailed(tx.payoutId, tx.id, reason);
}
}
if (isPayoutUpdatedEvent(event)) {
// The batch changed status: "TOTAL_PAYMENT", "PARTIAL_PAYMENT", "REJECTED" —
// or "PENDING_APPROVAL" / "NOT_APPROVED" when the approval flow is enabled.
// reconcileBatch(event.data.payout.id, event.data.payout.status);
}
// Always 2xx fast — Wompi retries up to 3 more times otherwise.
return new Response("OK", { status: 200 });
};
4. Reconcile without webhooks
If events can’t reach you (or as a safety net), poll the batch:
const [payoutError, payout] = await payouts.getPayout(payoutId, {
apiVersion: "v2",
});
if (payoutError) throw payoutError;
if (payout.status === "PARTIAL_PAYMENT") {
// Find out which transactions failed and why.
const [pageError, page] = await payouts.listPayoutTransactions(
payoutId,
{},
{ apiVersion: "v2" },
);
if (pageError) throw pageError;
const failed = page.records.filter((tx) =>
["FAILED", "REJECTED"].includes(tx.status),
);
}