Integré Bancard vPOS en Astro 6 usando Astro Actions, sin WooCommerce y sin que las credenciales salgan del servidor. El checkout usa el iframe oficial de Bancard: los datos de la tarjeta nunca pasan por mi backend, y el flujo completo está en código que yo controlo. Menos capas entre tu código y el resultado.

Los plugins de pago para WordPress funcionan. También te encadenan a una pila tecnológica que no controlás: actualizaciones de WooCommerce que rompen el checkout en producción, dependencias desactualizadas, y claves de API que a veces terminan en la base de datos del CMS. La integración directa cambia esa ecuación.


Lo que cambia cuando sacás el plugin de la ecuación#

La promesa del plugin es velocidad de implementación. El costo real es dependencia:

Plugin vs. integración directa
Aspecto Plugin WooCommerceAstro Action + vPOS
CredencialesEn la DB del CMS (configurable pero riesgoso)Variables de entorno del servidor, nunca en el cliente
Datos de tarjeta en tu servidorPosible según el pluginNunca: iframe de Bancard (PCI DSS)
Stack requeridoWordPress + WooCommerce + plugin específicoAstro 6 + 1 Action + bancard-checkout-js
Control sobre el flujoEl que el plugin permiteTotal: adaptás la UX a tu producto
Dependencia de tercerosAlta (mantenedor del plugin)Solo la API oficial de Bancard

El cambio más importante no es técnico, es de control. Cuando el checkout está escrito por vos, sabés exactamente qué pasa cuando Bancard actualiza su API. El mismo principio que apliqué cuando migré este sitio a Astro 6 con 0 KB de JS en el cliente.


¿Qué es Bancard vPOS y por qué es la pasarela de pagos estándar en Paraguay?#

vPOS es la solución de Bancard para cobros online en Guaraníes. Acepta tarjetas de crédito (Visa, Mastercard, American Express, Diners, entre otras), débito y la billetera Zimple.

Si tu e-commerce opera en Paraguay y necesitás aceptar pagos online, vPOS es la integración de referencia para e-commerce en Paraguay y una de las pasarelas con mayor adopción bancaria local. El flujo tiene tres actores: tu servidor, la API de Bancard, y el iframe que Bancard renderiza en tu página.

Así funciona el flujo completo:

  1. Tu servidor llama a la API: con las credenciales de Bancard (nunca en el cliente), generás un token MD5 y llamás al endpoint single_buy. Bancard te devuelve un process_id.

  2. El cliente monta el iframe: con ese process_id, la librería bancard-checkout-js renderiza el formulario de pago de Bancard en tu página. El usuario ingresa su tarjeta directamente en el dominio de Bancard.

  3. Bancard notifica tu servidor: cuando el pago se completa (o falla), Bancard hace un POST a tu webhook. Tu servidor valida la respuesta y actualiza el estado del pedido.

Un detalle que no es negociable: bancard-checkout-js no tiene alternativa de redirect puro. Los datos de la tarjeta nunca pasan por tu servidor porque el iframe pertenece al dominio de Bancard.


El código: servidor genera el token, cliente monta el iframe#

Astro Actions encajan perfecto en este flujo: permiten ejecutar lógica server-side sin exponer credenciales ni crear una API REST separada. El resultado es que toda la integración de pagos queda en un solo archivo, type-safe, sin endpoints adicionales que mantener.

Paso 1: Variables de entorno#

BANCARD_PUBLIC_KEY=tu_clave_publica
BANCARD_PRIVATE_KEY=tu_clave_privada
BANCARD_API_URL=https://vpos.infonet.com.py  # staging: vpos.infonet.com.py:8888

Paso 2: El Action server-side#

src/actions/index.ts
import { defineAction } from "astro:actions";
import { z } from "zod";
import { createHash } from "node:crypto";

export const server = {
  iniciarPago: defineAction({
    input: z.object({
      shopProcessId: z.string().min(1),
      amount: z.string().regex(/^\d+$/, "Monto en PYG sin decimales: '150000'"),
      description: z.string().max(100),
    }),
    handler: async ({ shopProcessId, amount, description }, _ctx) => {
      const privateKey = import.meta.env.BANCARD_PRIVATE_KEY;
      const publicKey = import.meta.env.BANCARD_PUBLIC_KEY;

      // El token se genera en el servidor: la clave privada nunca llega al cliente
      const token = createHash("md5")
        .update(`${privateKey}${shopProcessId}${amount}PYG`)
        .digest("hex");

      const response = await fetch(
        `${import.meta.env.BANCARD_API_URL}/vpos/api/0.3/single_buy`,
        {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({
            public_key: publicKey,
            operation: {
              token,
              shop_process_id: shopProcessId,
              amount,
              currency: "PYG",
              additional_data: description,
              return_url: `${import.meta.env.SITE}/checkout/confirmado/`,
              cancel_url: `${import.meta.env.SITE}/checkout/cancelado/`,
            },
          }),
        },
      );

      if (!response.ok) {
        throw new Error(`Bancard API error: ${response.status}`);
      }

      const data = await response.json();
      return { processId: data.process_id as string };
    },
  }),
};

Paso 3: El formulario con el iframe de Bancard#

src/components/CheckoutForm.astro
---
const shopProcessId = crypto.randomUUID();
---

<form id="checkout-form" data-shop-process-id={shopProcessId}>
  <!-- tu resumen de pedido aquí -->
  <button type="submit">Pagar en Guaraníes</button>
</form>

<div id="bancard-iframe" style="display:none;"></div>

<script>
  import { actions } from "astro:actions";

  document.getElementById("checkout-form")?.addEventListener("submit", async (e) => {
    e.preventDefault();
    const shopProcessId = (e.currentTarget as HTMLFormElement).dataset.shopProcessId!;

    const { data, error } = await actions.iniciarPago({
      shopProcessId,
      amount: "150000", // Guaraníes: entero sin decimales
      description: "Compra en mi tienda",
    });

    if (error || !data) {
      console.error("Error al iniciar pago", error);
      return;
    }

    // Mostrá el iframe con el formulario de Bancard
    const container = document.getElementById("bancard-iframe")!;
    container.style.display = "block";
    // @ts-ignore: librería cargada vía CDN
    Bancard.Checkout.createForm("bancard-iframe", data.processId);
  });
</script>

El script de bancard-checkout-js lo cargás desde el CDN de Bancard en tu layout o directamente en el <head>. Lo referenciás así:

<!-- Staging: vpos.infonet.com.py:8888 | Producción: confirmá la URL en el Portal de Comercios -->
<script src="https://vpos.infonet.com.py/checkout/javascript/dist/bancard-checkout-3.0.0.js"></script>
🛠️¿Cómo sé si el pago se confirmó realmente?Haz clic para expandir

Cuando el pago se completa, Bancard envía una notificación server-to-server a la URL que configurás en el Portal de Comercios (distinta del return_url, que solo redirige al usuario). El endpoint tiene que hacer cinco cosas en orden:

import type { APIRoute } from "astro";

export const POST: APIRoute = async ({ request }) => {
  const payload = await request.json();
  const { operation } = payload;

  // 1. Validar el token MD5 de confirmación (fórmula en el Portal de Comercios)
  const isValid = validateConfirmationToken(operation);
  if (!isValid) return new Response("Unauthorized", { status: 401 });

  // 2. Verificar el estado de la operación
  if (operation.response_code !== "00") {
    return new Response("OK", { status: 200 }); // no aprobado, pero no es un error tuyo
  }

  // 3. Idempotencia: ignorar si ya fue procesado
  const order = await getOrderByProcessId(operation.shop_process_id);
  if (!order || order.status === "paid") {
    return new Response("OK", { status: 200 });
  }

  // 4. Actualizar el pedido
  await markOrderAsPaid(operation.shop_process_id);

  // 5. Responder 200 rápido: Bancard reintenta si tarda o falla
  return new Response("OK", { status: 200 });
};

Cuatro reglas que no podés ignorar:

  • Nunca confíes solo en return_url. El redirect le dice al usuario que terminó; el webhook es la única confirmación real del servidor.
  • El webhook puede llegar duplicado. Bancard reintenta si no recibe 200 en tiempo. La idempotencia en el paso 3 te protege de procesar el mismo pago dos veces.
  • El webhook puede llegar tarde. Minutos, a veces más. Tu UI necesita mostrar un estado intermedio (“procesando”) hasta que el webhook confirme.
  • La actualización tiene que ser idempotente. Si markOrderAsPaid se ejecuta dos veces con el mismo shop_process_id, el resultado tiene que ser el mismo.

Lo que rompió la integración (y no aparece en la documentación)#

Timeouts en staging#

El sandbox de Bancard tiene tiempos de respuesta erráticos. En producción la API responde en menos de 500ms; en staging podés ver timeouts de 8-10 segundos que no reflejan nada del comportamiento real. Es el problema clásico de paridad entre ambientes llevado al checkout.

Perdí dos horas pensando que mi código tenía un error. Era el ambiente de pruebas.

El estado pendiente no es instantáneo#

Cuando el usuario completa el formulario del iframe, el pago no se confirma de inmediato. El webhook puede tardar varios minutos. Si mostrás “Pago confirmado” basándote en el redirect de return_url en lugar del webhook, vas a tener órdenes marcadas como pagas que todavía están procesando.

La regla es simple: return_url es para la UX del usuario; el webhook es para el estado real del pedido.

shop_process_id único por transacción#

Tiene que ser distinto en cada intento. Si el usuario cierra el navegador y vuelve a intentar el checkout, generás un ID nuevo, no reusás el anterior.


¿Integración directa o plugin? Cuándo elegir cada uno#

Cuándo vale la pena integrar Bancard sin plugins

Tiene sentido si...

  • Tu stack es Astro, Next.js o cualquier framework moderno sin WordPress
  • Necesitás control total sobre la UX del checkout
  • Tu equipo es cómodo con TypeScript y APIs REST
  • Tenés múltiples flujos de pago (suscripciones, pagos parciales)

Un plugin puede ser mejor si...

  • Tu tienda ya está en WooCommerce y funciona bien
  • No tenés un desarrollador disponible para mantener el código
  • El tiempo de implementación es crítico y un plugin existente cubre tus casos

La integración de pagos directa no es más inteligente que un plugin en abstracto. Es la decisión correcta cuando querés control, tu stack lo permite, y tenés un dev que va a mantener el código.

¿Necesitás integrar Bancard en Astro, Next.js o una tienda custom?

Especialidad: Integración de pagos para e-commerce en Paraguay

Desarrollo integraciones vPOS seguras, server-side y mantenibles: checkout desde cero, auditoría de tu pasarela de pagos existente, o migración desde WooCommerce. Con una integración que tu equipo puede mantener y entender.

g CO₂
Hugo Campañoli
Escrito por

Hugo Campañoli

Arquitecto de Software & Especialista en Rendimiento Web. Construyo ecosistemas digitales de alta velocidad que dominan los buscadores y deleitan a los usuarios. Liderando la ingeniería de contenido desde Itapúa.