Vendsu: un mercado justo para México, construido con Go, htmx y SQLite
Si alguna vez vendiste algo en línea en México, ya conoces la parte triste de la historia: vendes un vestido en $700 y la plataforma se queda con $87… o con $145. Publicar “gratis” que termina costando una mensualidad, un fijo por unidad, el “envío gratis” que pagas tú, y la comisión que solo entiendes cuando ya vendiste. En algunas plataformas de moda usada, esa venta de $700 se queda en $555 para quien vendió: 20.7% efectivo.
Vendsu nace de ese hastío. Es simple de enunciar y difícil de sostener:
Publicar es gratis. La comisión es 5% + $5.00 MXN por artículo vendido. Es la misma para todos: la tienda grande y la persona que vende por primera vez. Sin mensualidades, sin letras chicas.
Esa venta de $700 en Vendsu cuesta $40. No hay truco: el producto completo — interfaz, correos, mensajes de error — habla español mexicano, y la cuenta está en la página de inicio con calculadora.
Este post no es para venderles Vendsu (aún estamos cerrando detalles antes de abrir puertas), sino para contar cómo lo estamos construyendo. Porque resultó que la decisión técnica más importante fue la misma que la decisión de negocio: quitarle grasa al intermediario.
El stack: lo aburrido que sí funciona
Un marketplace es, en el fondo, un CRUD con dinero encima y una obsesión por la confianza. Para eso, el ecosistema de moda propone un frontend en React con su build, un backend en otro framework con su build, un ORM, una cola, tres bases de datos y un equipo de plataforma. Nosotros elegimos lo contrario:
- Go con el
net/httpde la biblioteca estándar y enrutamiento por patrones. Sin framework web. - templ: plantillas compiladas a Go. Un error de plantilla es error de compilación, no un 500 en producción. Y el escape anti-XSS ocurre por diseño, no por disciplina.
- htmx para el carrito y las respuestas
parciales — y todo degrada sin JavaScript: el mismo formulario
funciona con
action=y method POST. - SQLite (el driver puro en Go de modernc.org, sin cgo) con migraciones embebidas en el binario.
El resultado es un binario estático y un archivo de base de datos. Un
./vendsu y está corriendo. El día que necesitemos Postgres, la capa
de datos está aislada; pero la lección repetida del software es que
SQLite aguanta muchísimo más de lo que la intuición de 2015 acepta.
$ make build && ./vendsu
Vendsu escuchando url=http://localhost:8080 puerto=8080
El dinero nunca pasa por float
La regla número uno de cualquier sistema de pagos: el dinero es un entero de centavos. La regla número dos: el JSON también.
Los PSP mandan y reciben cantidades decimales (703.50), y la mayoría
de las integraciones las convierten a float64 para “facilitar”. Nosotros no:
// En el JSON saliente viaja json.Number, no float:
"marketplace_fee": json.Number(pesos(3750)), // → 37.50 exacto
// Y el entrante se parsea textualmente, dígito por dígito:
func centavosDeNumero(n json.Number) int64 { ... }
703.50 entra como texto y sale como 70350. Ni un float64 toca la
trayectoria del dinero — ni hacia la pasarela, ni desde sus webhooks.
Es una de esas decisiones que no genera ningún ticket… hasta que la
omitiste y un día un centavo se pierde por redondeo en mil pedidos.
Pagos donde la plataforma nunca toca el dinero
Aquí está la parte que más nos gusta contar.
El modelo clásico de marketplace cobra todo a la plataforma y luego dispersa a los vendedores. Funciona… hasta que piensas en lo que significa: retener impuestos ajenos, conciliar saldos, y custodiar dinero que no es tuyo (con todo lo que eso implica regulatoriamente en México).
Vendsu usa split de pagos 1:1: cuando compras, la pasarela cobra a nombre del vendedor y nos retiene la comisión en el mismo movimiento. El principal nunca entra a una cuenta de Vendsu. El vendedor conecta su cuenta de Mercado Pago una vez (OAuth), y de ahí en adelante su dinero llega directo. Menos intermediación para nosotros, menos riesgo para todos.
Los tokens OAuth de cada vendedor se guardan cifrados en reposo (AES-256-GCM con una llave que no vive en la base). Un volcado de la base no le sirve a nadie para mover dinero.
Webhooks: desconfiar por diseño
Todo marketplace que cobra de verdad vive de webhooks, y los webhooks son la puerta favorita de los problemas. Tres reglas que aplicamos religiosamente:
- La firma primero. Cada notificación trae un HMAC-SHA256 sobre
un manifiesto conocido (
id:…;request-id:…;ts:…;). Verificación en tiempo constante. Firma mala → 401 y ni leemos el cuerpo. - Nunca confiar en el cuerpo. El webhook solo nos dice “mira el pago X”. Vamos al PSP, consultamos el pago por GET, y con eso tomamos decisiones. El cuerpo del webhook es un aviso, jamás una fuente de verdad.
- Idempotencia real. Los webhooks llegan repetidos, fuera de orden y a deshoras. Cada transición de pedido está escrita para poder ejecutarse dos veces sin doble efecto.
if !s.MP.ValidarFirma(firma, notif.ID, r.Header.Get("X-Request-Id")) {
http.Error(w, "firma inválida", http.StatusUnauthorized)
return
}
// La fuente de verdad es la reconsulta, no la notificación:
info := s.MP.GetPayment(ctx, token, notif.ID)
Buscar bien en español es una función de producto
La búsqueda de un catálogo mexicano tiene un requisito que en inglés
no existe: “audifonos” tiene que encontrar “Audífonos”. La
solución típica — un LIKE tras quitar acentos — funciona, pero no
ordena por relevancia y se degrada con volumen.
Usamos FTS5 de SQLite con el tokenizador
unicode61 remove_diacritics 2, ranking bm25, y una construcción
de consulta que convierte lo que tecleó la persona en términos
sanitizados con prefijo ("audifo" → audifo*, que completa
“Audífonos” a media palabra). Los resultados de búsqueda se ordenan
por relevancia; el resto del catálogo, por novedad.
Bonus de hacking que nos costó una tarde: el driver puro de SQLite en
Go no soporta el INSERT especial de borrado de FTS5 (VALUES ('delete', …)), ni en triggers ni en sentencias sueltas. La solución
— borrar por rowid con un DELETE ordinario y reinsertar — está
documentada en la migración para el próximo viajero.
Fotos que no pesan 8 MB
Un marketplace de personas vendiendo sus cosas recibe fotos tomadas con el teléfono a máxima resolución: originales de 8 MB que, en la arquitectura ingenua, se sirven enteros en cada tarjeta del catálogo.
Toda subida se decodifica (JPG, PNG, GIF, WEBP), se re-escala y se re-codifica en el servidor: una versión grande de máx 1600 px y una miniatura de 480 px para las tarjetas. El re-encode tiene bonus de seguridad: elimina políglotos de imagen y metadatos de paso. Y las miniaturas ahorrarán terabytes de ancho de banda antes de que notemos la factura.
La confianza es un feature, no un eslogan
En C2C, la primera estafa viral te mata. Por eso la confianza está en el producto desde antes del lanzamiento:
- Dinero retenido hasta la entrega confirmada por quien compró.
- Disputas con ambas partes escuchadas: 5 días hábiles de ventana tras la entrega (alineada a la ley mexicana de consumo a distancia), resolución documentada, reembolso ejecutado por la pasarela — no un “ya queda” interno.
- Reputación que no se compra: solo puedes reseñar a un vendedor si compraste y recibiste; las insignias se calculan sobre reseñas de pedidos reales.
- Envíos con tarifa negociada: el vendedor cotiza entre varias paqueterías desde su panel y compra la guía al mayoreo — el superpoder que solo las plataformas enormes tenían.
Probamos contra pasarelas falsas (y eso salva domingos)
No se puede probar un flujo de dinero contra producción, y los
“sandbox” de los PSP suelen ser lentos y caprichosos. Entonces cada
prueba de integración monta su PSP falso con httptest: un
servidor que responde preferencias, pagos y hasta calcula la firma
HMAC correcta para sus propios webhooks.
Las pruebas recorren flujos completos de verdad: registrarse,
publicar, carrito, checkout, preferencia con la comisión exacta
("marketplace_fee":40.00, carácter por carácter), webhook firmado,
pedido pagado, guía comprada, disputa, reembolso. Hoy son 48 y el
número sube cada semana. Varios bugs reales (un login que no
rechazaba cuentas suspendidas, un parser de centavos que perdía los
montos menores a $1) los atrapó esta suite, no producción.
Lo honesto por fuera, lo simple por dentro
Hay una tesis más profunda debajo de todo esto. Un marketplace que cobra 15% necesita una máquina enorme: tiene que pagar anuncios, subsidios y equipos que justifiquen el peaje. Un marketplace que cobra 5% + $5 y protege la transacción puede ser un binario, una base SQLite y un equipo obsesionado con que el vendedor gane más.
La austeridad técnica no es tacañería: es coherencia. Cada capa que no necesitamos construir es margen que no le cobramos a quien vende.
Estamos poniendo los últimos detalles para abrir puertas en México. Si vendes cosas en línea y estás harto de las comisiones — o si eres nerd y quieres discutir por qué elegimos no usar React — mantente cerca. La calculadora de comisiones ya está encendida.
¿Comentarios? Este post se escribió para ser discutido: encuentros de Go, grupos de htmx y cualquiera que defienda que SQLite “no escala” — traigan datos.
← volver a posts