If you've ever worked on an e-commerce project in Chile, sooner or later you bump into Transbank. There's no avoiding it.
I built a reference template that use NestJS on the backend and Next.js 16 on the frontend, and in this post I want to walk you through the whole thing: what Webpay Plus actually is, why the integration looks the way it does, and how each piece fits together.
Github Repository Reference: Link
A bit of context
Webpay Plus is one of the most important online payment methods in Chile. The user experience is straightforward: your customer enters an amount on your site, gets redirected to Transbank's branded payment page, fills in their card details there, and comes back to your site with a result.
From a UX perspective it's not as slick as Stripe Elements or a fully embedded checkout — the user always sees Transbank's domain in the address bar during the payment — but from a developer's perspective that's actually a feature.
You never touch card numbers. PCI scope stays minimal. Transbank handles 3D Secure, fraud rules, and bank routing.
The trade-off is that the integration model is what you might politely call classical. It's built around full-page redirects and form POSTs, not modern APIs with JSON responses and webhooks. That's important to understand up front, because it shapes every decision you'll make in the code.
The integration flow, step by step
Before looking at any code, it helps to have a clear mental model of what's happening. There are three distinct moments where your system talks to Transbank's system, and each one has a specific shape.
First Step: When the customer clicks "Pay", the backend asks Transbank to create a
transaction (amount, order ID, return URL). Transbank returns a transaction token and a redirect URL where you must send the user.Second Step: Transbank requires the redirect to be an HTTP POST with the token in a
token_wsform field, so you must generate an HTML form with a hiddentoken_wsinput and submit it programmatically to avoid token leakage via history/referrers. Once on Transbank's page, the user enters their card details and either completes or cancels the payment. This step is entirely out of your hands — which, again, is the point.Third Step: When Transbank redirects back with the
token_ws, your backend calls Transbank to validate the token and retrieve the transaction details (response_code0= approved); then you route the user to a success or error page.
Note: Transbank can POST the return (e.g., on timeout, cancel, or abandonment) to the same return URL instead of a GET, so make sure your backend accepts and handles both
POSTandGETto avoid broken pages.
Sequence diagram of the steps
Backend Code
The controller stays thin — it reads the incoming parameters, decides which case it's in, and delegates the actual SDK call to the service. Here's the commit handler, which is where most of the interesting logic lives:
Controller example
private tx = WebpayPlus.Transaction.buildForIntegration(
IntegrationCommerceCodes.WEBPAY_PLUS,
IntegrationApiKeys.WEBPAY,
);
@All('/commit')
async callback(@Req() req: Request, @Res() res: Response) {
const params = req.method === 'GET' ? req.query : req.body;
const tokenWs = params['token_ws'] as string;
const tbkToken = params['TBK_TOKEN'] as string;
const tbkOrdenCompra = params['TBK_ORDEN_COMPRA'] as string;
const tbkIdSession = params['TBK_ID_SESSION'] as string;
// Timeout
if (!tokenWs && !tbkToken) {
this.logger.warn(
`[${req.method}] /webpay/commit timeout tbkOrdenCompra=${tbkOrdenCompra} tbkIdSession=${tbkIdSession}`,
);
return res.redirect('http://localhost:4000/result/error');
}
// Abort
if (tbkToken && !tokenWs) {
this.logger.warn(
`[${req.method}] /webpay/commit abort tbkToken=${tbkToken} tbkOrdenCompra=${tbkOrdenCompra}`,
);
return res.redirect('http://localhost:4000/result/error');
}
// Commit TX
this.logger.debug(`[${req.method}] /webpay/commit token=${tokenWs}`);
const response = await this.webpayService.commitTx(tokenWs);
if (response.response_code === 0) {
this.logger.debug(`Commit successful, redirecting to success`);
return res.redirect('http://localhost:4000/result/success');
}
this.logger.warn(`Commit failed responseCode=${response.response_code}, redirecting to error`);
return res.redirect('http://localhost:4000/result/error');
}
As you see, the Transbank SDK does most of the heavy lifting. As a example, here's what the "create transaction" call looks like in the webpay.service.ts
async createTx(createWebpayDto: CreateWebpayDto) {
//TODO: Change buyOrderId with your implementation
const buyOrder = 'GT-' + Math.floor(Math.random() * 10000) + 1;
//TODO: Change sessionId with your implementation
const sessionId = 'Session-' + Math.floor(Math.random() * 10000) + 1;
const returnUrl = 'http://localhost:3000/webpay/commit';
this.logger.debug(
`Creating transaction buyOrder=${buyOrder} sessionId=${sessionId} amount=${createWebpayDto.amount}`,
);
const response = await this.tx.create(buyOrder, sessionId, createWebpayDto.amount, returnUrl);
this.logger.debug(`Transaction created token=${response.token} url=${response.url}`);
return response;
}
Note: The IntegrationCommerceCodes and IntegrationApiKeys constants are public test credentials that Transbank uses as sandbox.
When you go to production, you replace these with your real credentials (which Transbank gives you after a commercial onboarding process) and switch Environment.Integration to Environment.Production.
The returnUrl is the URL Transbank will send the user back to after the payment.
This is critical: it has to be reachable from the user's browser (so no internal hostnames)
And the path you specify is where the callback handler will live on your backend.
Frontend Code
The trickiest part of the integration is step 2: Transbank requires a full-page HTTP POST with the token in a form field — a regular fetch or router.push won't work.
// PaymentForm.tsx
export function PaymentForm() {
const [amount, setAmount] = useState("");
const [tx, setTx] = useState<{ token: string; url: string } | null>(null);
const [isPending, startTransition] = useTransition();
const formRef = useRef<HTMLFormElement>(null);
useEffect(() => {
if (tx) formRef.current?.submit();
}, [tx]);
function handlePay() {
startTransition(async () => {
const result = await createTransaction(Number.parseInt(amount, 10));
if ("error" in result) { setError(result.error); return; }
setTx(result);
});
}
return (
<>
{tx && (
<form ref={formRef} method="post" action={tx.url} className="hidden">
<input type="hidden" name="token_ws" value={tx.token} />
</form>
)}
{/* amount input + pay button */}
</>
);
}
The hidden form only enters the DOM once the token is ready. The useEffect watches for that and calls submit() immediately — the browser performs a full-page POST to Transbank's URL, and the user lands on their payment page.
Then we need a server action that keeps the backend URL off the client entirely:
'use server';
export async function createTransaction(amount: number) {
const response = await fetch(`${process.env.API_URL}/webpay`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount }),
});
return response.json() as Promise<{ token: string; url: string }>;
}
The browser never sees API_URL. The action runs on the Next.js server, proxies the request to NestJS internally, and only { token, url } reaches the client. You can change your backend URL without touching the client bundle.
Handling the result
When Transbank redirects the user back, the backend commits the transaction and redirects to one of two pages in Next.js: /result/success or /result/error. Both are simple static pages — success shows a confirmation, error covers rejections, cancellations, and timeouts alike.
Testing the whole thing
You get a fully functional sandbox without having to sign anything or talk to anyone. They publish test card numbers you can use to simulate approved transactions, declined transactions, and various edge cases.
You can run the full flow end to end on your laptop, with no real money moving anywhere.
To try the template:
git clone https://github.com/figonzal/webpay-nestjs-nextjs
cd webpay-nestjs-nextjs
- First, run the backend:
cd webpay-nestjs && pnpm install && pnpm start:dev
- The, run the frontend:
cd webpay-nextjs && pnpm install && pnpm dev
Open http://localhost:4000, type any amount, and walk through the full payment flow with one of the test cards. You'll see the redirect to Transbank, the payment form on their side, and the return to your success or error page.
Closing thoughts
The Webpay Plus integration looks intimidating at first because the flow involves multiple redirects, two different HTTP verbs on the same endpoint, and a hidden form trick that feels out of place in 2026. But once you see the three handshakes clearly — create, redirect, commit — the rest is mechanical.
A few things worth keeping in mind as you ship this: the commit endpoint needs to handle three distinct cases, not two. Transbank can return a token_ws (normal flow), a TBK_TOKEN without token_ws (user cancelled), or neither (timeout). The Server Action boundary is also worth preserving — proxying through Next.js keeps your NestJS URL off the client entirely, which pays off when you move from integration to production credentials.
The SDK does most of the work. Your job is to wire the pieces together correctly and handle the unhappy paths.
If this was useful, the full template is on GitHub — Next.js frontend, NestJS backend, Transbank SDK wired up in integration mode, and the full commit handler with all three cases covered. Clone it, run it with the test cards from Transbank's docs, and walk through the complete flow on your laptop before writing a single line of your own integration code. It's a much better starting point than a blank project.
If you liked the article, feel free to give a thumbs up. You will motivate me to write more articles like this one. Thanks! ❤
¡Happy Coding!
Top comments (0)