Luxura
App de citas por invitación, Bogotá y Medellín. Once semanas de un repo vacío a Google Play. La construí, corro su servidor, pagué su pauta. Cada número de abajo salió de la base de datos que sigue en producción.
- Rol
- Fundador y líder técnico: producto, backend, infraestructura, crecimiento
- Diseño
- Victor ↗
- Ventana de construcción
- 2026-01-21 → 2026-04-06
- Corre en
- Una máquina Hetzner, Coolify, Docker



$ cat ORIGIN.md
Empezó como una observación, y un amigo al que le importaba cómo se veía.
A las apps de citas les faltaba mucho. La experiencia se sentía barata y los incentivos estaban mal. Pero unas cuantas ya habían validado el modelo mejor, donde un lado invita y el otro elige, así que el riesgo no era la idea sino la ejecución.
Me pregunté por qué no podía construir una mejor. Empezó como un proyecto chico y se volvió algo que no pude soltar. Victor lo diseñó conmigo, pantalla por pantalla: el login, las tarjetas de perfil, las landing, la dirección oscura con dorado sobre la que todavía corre todo. Fuimos despacio a propósito. El objetivo nunca fue tener más funciones. Era algo que se sintiera bien de usar y que se viera cuidado.
$ cat platform.md
Una web estaba bien. La mayoría quería una app.
Capacitor volvió la web y la app de Android un solo artefacto: un repo, un deploy, siempre que fuera disciplinado con el layout responsive y conectara yo mismo los plugins nativos de push, GPS y cámara.
Después llegaron las preguntas de las que nadie te avisa. Publicar en Google Play toma semanas. Son dos semanas de pruebas cerradas obligatorias con doce testers antes de que te dejen acercarte a producción. Los pagos fueron otra: RevenueCat para las suscripciones de tienda, y cero pagos web en el primer release, porque una sola superficie de cobro ya era suficiente para equivocarse. iOS lo dejé para después y nunca salió. El target de Capacitor está en el repo. Android es donde está este mercado.
Una consecuencia sigue siendo lo que más me gusta del build. El shell carga la web en vivo desde mi servidor, así que un cambio de JavaScript llega a todos los teléfonos instalados en el siguiente deploy. Solo se necesita un release de tienda cuando cambia código nativo.
$ cat README.md
Las apps de citas abiertas se ahogan en volumen sin esfuerzo.
Todos envían, nadie elige, y quienes reciben se van primero. Luxura lo invierte. Un lado navega y gasta un número estrictamente limitado de invitaciones. El otro lado las recibe y elige. La escasez del lado que envía es el producto. Una invitación le cuesta algo a quien la manda, así que significa algo para quien la recibe.
Otras apps ya habían validado el modelo, así que la ejecución era la parte que me interesaba. Empezó como un proyecto con un amigo y terminó siendo un producto con una cuenta de pauta encima.
# Restricciones
- Mercado colombiano. Techo de precio cerca de $5/mes, no $15.
- Android primero, gamas medias, datos móviles. El peso del bundle decidió qué salía.
- Presupuesto de una persona. Ningún proveedor por encima de decenas de dólares al mes.
- Español e inglés a la par desde el día uno, 1.911 llaves cada uno.
$ ./bin/report --totals
$ ./bin/report --ads-off 2026-03-08
Apagué la pauta un día entero para saber qué había construido de verdad.
Todo se ve sano mientras la pauta corre, que es justo cuando no puedes distinguir demanda de gasto. Así que cerré la llave por un día. Entre más se parecía la métrica al producto, menos se movió.
7 mar, con pauta8 mar, sin pauta, en color según cuánto cayó
La parte alta del embudo era casi toda comprada. Los 48 registros que igual llegaron sin pauta son lo que era el descubrimiento orgánico. Pero la gente que ya estaba adentro siguió hablando. El chat tenía tres días y ya era lo más pegajoso del producto. Esa comparación definió el roadmap de las dos semanas siguientes. Esa semana también saqué una tabla de retención que no voy a usar acá: mide una ventana de actividad reciente, así que favorece a quien se registró último, y el día sin pauta es la comparación más limpia porque ambas cohortes se midieron el mismo día.
$ cat sdd/proposals/role-based-app-overhaul.proposal.md
La mitad del tráfico que estaba pagando moría antes de ver a otro ser humano.
Producción estaba sólida y todo estaba probado. Entonces abrí la cuenta de pauta y la plata empezó a evaporarse en la puerta. El registro pedía teléfono, luego perfil, luego fotos, luego una verificación facial en vivo. Solo entonces veías un perfil.
No lo parché por instinto. Medí, escribí el diagnóstico, y lo que salió a producción fue el arreglo.
## Motivation (Why) The current onboarding has a 47% completion rate (846 users stuck at verification). Gender is hardcoded to role (MALE=inviter, FEMALE=invitee), which blocks same-sex dynamics. **Core insight**: Let users see the candy shop immediately, but gate interaction behind profile completion and payment.
Textual de la propuesta, fechada 2026-03-09. Salió dos días después como un solo cambio: te registras, eliges un rol, eliges una orientación, y estás adentro. Quien invita ve el feed completo de una. Quien recibe ve una bandeja demo, etiquetada como tal, con perfiles reales. El muro de tres pasos sigue existiendo. Solo que no se levanta hasta que intentas invitar o aceptar. Cobra la interacción, no la entrada.
$ psql -c "verification completion, before vs after"
SELECT CASE WHEN u."createdAt" < '2026-03-11' THEN 'before' ELSE 'after' END AS era, count(*) AS with_profile, count(*) FILTER (WHERE u."onboardingStep" IN ('VERIFIED','ACTIVE')) AS verified, round(100.0 * count(*) FILTER (WHERE u."onboardingStep" IN ('VERIFIED','ACTIVE')) / count(*), 1) AS pct FROM users u JOIN profiles p ON p."userId" = u.id GROUP BY 1; era | with_profile | verified | pct ---------+--------------+----------+------- before | 1994 | 1027 | 51.5 after | 1057 | 678 | 64.1 (2 rows)
Ejecutado contra la base de datos en producción en agosto de 2026. Sobre todos los registros la mejora es menor, de 44,3% a 48,7%, porque el cambio también dejó entrar a un grupo grande que nunca empezó un perfil. Semana a semana: la peor semana de pauta verificó 40,4%; para el 23 de marzo llegó a 56,8%.
1.239 usuarios están hoy en el primer paso, dentro de la app, sin haber armado un perfil. Es una fuga real y es la que habría atacado después. Sigue siendo la mejor fuga posible: esa gente vio el producto antes de decidir, que era justamente el punto.
$ ls -la ./engineering
face-api.js pedía unos 10MB de modelos. En un Android de gama media con datos móviles colombianos eso es un producto muerto antes de renderizar. Jeeliz WebGL hace el trabajo de gestos en cerca de 1,4MB. Human.js quedó como segundo motor detrás de una variable de entorno, y en modo auto carga primero y cae a face-api.js sin que se vea la falla, porque una ruta de verificación con una sola implementación es una ruta con una sola forma de fallar.
// content moderation: resolve in order, first success wins 1. self-hosted NudeNet + InsightFace + OpenCV // safety AND quality 2. Azure Content Safety ‖ Computer Vision // in parallel 3. Google Cloud Vision // one call, both 4. Sightengine nudity · offensive · type · face // flagged no_face | screenshot → a small vision model re-judges THAT flag // video → frames pulled and judged one at a time // rejected → stored as a perceptual dHash, re-upload caught for free
Los proveedores grandes gritan lobo, y un rechazo falso cuesta un usuario real que ya tenía un pie afuera. Entonces una foto marcada recibe una segunda opinión sobre esa marca específica, de un modelo de visión pequeño, de forma independiente, y solo un visto bueno en todas las marcas la sube. La pieza más barata es mi favorita: cada foto rechazada se guarda como hash perceptual, así que una resubida de la misma imagen se atrapa al instante, sin llamar a ningún proveedor. La gente resube todo el tiempo.
Las imágenes se comprimen en el navegador con tope de 500KB / 1920px, así que el 80 a 90% de los bytes nunca sale del teléfono. El video se comprime en el servidor dentro de un job (H.264, 720p, CRF 23) con una bandera de procesamiento en la fila para que la interfaz muestre el estado mientras corre. Cerca de 87% menos almacenamiento sin costo marginal más allá del CPU que ya pagaba. Cuando pagas tu propia pauta, cada línea recurrente de proveedor sale del mismo presupuesto.
Socket.IO se monta sobre un servidor HTTP que envuelve el handler de Next.js, y por eso esta app arranca con un punto de entrada propio y no con next start. Eso cierra una puerta de verdad. Ningún host serverless va a correr esto otra vez. Fue el intercambio correcto porque la alternativa era polling, y porque ya tenía mi propia máquina. 424 mensajes a la mañana siguiente, 71,4% de las conexiones chateando, y 15.480 mensajes hoy.
// phone OTP, eight weeks raw Twilio → Twilio Verify → raw Twilio Messaging → Firebase Phone Auth → Plivo Verify → Twilio + own otp_codes table → Infobip + Twilio fallback // three of those swaps happened on a single day: 2026-03-14
Cada cambio salió de las tasas de entrega en Colombia, del costo por mensaje, o de una superficie de error que no controlaba. Un solo código de error de Twilio inundó Sentry con lo que en realidad eran errores de tipeo de los usuarios. Nada en la app importa si la primera pantalla pierde usuarios, así que dejé de tratarlo como un problema de compras. El código ahora es dueño de los códigos, del rate limiting y de un circuit breaker, y el proveedor detrás es un valor de configuración. Con esa costura, cambiar de proveedor principal tomó un día en vez de un sprint. Ahí debí haber empezado.
$ cat economics.md
Pagué la pauta yo mismo, así que cada decisión volvía con un precio por registro encima.
El gasto y las cifras por unidad cubren el periodo de pauta hasta el 9 de marzo, cuando había 2.451 registros. Los totales del inicio de esta página son de por vida, hasta agosto.
Ochocientos dólares me mostraron dónde goteaba el embudo. La suscripción salió a $15/mes en un mercado que aguanta unos $5. Los planes semanales de unos $2,50 salieron el 17 de marzo, un mes después de que la plata ya estaba comprometida.
En el proyecto siguiente especifiqué la atribución bien, antes de gastar un peso, por esta línea exacta. La pauta sin atribución me dejó adivinando: puedo decirte qué compraron $800 en agregado y no puedo decirte qué campaña lo hizo.
$ cat scope.json
Además: CI con lint, typecheck y build en cada push · Sentry en cliente, servidor y edge con un registro de bugs · deep links y archivos de asset-links para las tiendas · anuncios recompensados con AdMob · suscripciones con RevenueCat y un job nocturno de reconciliación · una suite de Playwright sobre onboarding, navegación, invitaciones, verificación y admin · y un stack de crecimiento que publica solo en X, Threads y Facebook con imágenes de cita renderizadas en canvas.
$ cat method.md
1.326 commits en once semanas, junto con la cuenta de pauta.
Eso salió de un flujo agéntico, y más aún de las barandas que lo hacen sobrevivible.
- Una lista previa al envío donde cada punto corresponde a un incidente real. El punto 4 existe porque una función pasada de un Server Component a un Client Component tumbó el sitio el 19 de marzo.
- Desarrollo guiado por especificación para todo lo que cruzaba más de un subsistema: propuesta, spec, tareas numeradas, todo versionado. La reconstrucción del embudo salió así.
- Una revisión ciega de un segundo modelo antes de cada push, con un modelo que no es de Anthropic.
- CI que no deja llegar un error de tipos al servidor, porque uno llegó.
El volumen importa menos que en qué se gastó. Lo que cambió fueron los modos de falla. Menos typos y chequeos de null olvidados; muchos más módulo correcto, nunca conectado en la raíz de composición. La lista apunta justo a esos.
$ ls ./ad-creative
También hice los anuncios, y de ahí salió el siguiente producto.
Nadie me iba a entregar creativos. El video lo generaba en Kling, con control de movimiento para sacar los movimientos de cámara que quería. Para las locuciones grababa una toma y después cambiaba la voz con Cartesia o ElevenLabs hasta que el registro sonara a Bogotá y no a traducción. Después publicaba. Instagram, Facebook, varios perfiles, todos los días, a mano, encima del build. Generar tomaba minutos. Atender se comía el día.
Esa atención diaria es la que produjo mi siguiente producto, que tiene su propia página.



$ cat POSTMORTEM.md
No despegó, y señaló lo siguiente.
Luxura está viva y la gente la usa. El lado producto aguantó. El día que apagué la pauta, los registros cayeron 83% y los mensajes 11%. Lo que no pude sostener fue el gasto que mantiene viva la parte alta del embudo, con dos suscriptores pagos contra $800.
El trabajo de anuncios de arriba es lo que cambió la dirección. En algún punto de atender esas cuentas a diario me pregunté por qué lo hacía a mano, y unos amigos con tiendas pequeñas resultaron tener el mismo problema del otro lado, ahogados en mensajes que no alcanzaban a contestar. De ahí salió Storía. La construí en cuatro días de ese febrero, en medio de todo esto.
# Qué haría distinto
- Deja entrar a la gente antes de pedirle que pruebe nada. Lo aprendí comprándolo. Arreglarlo movió la verificación completada entre quienes empiezan un perfil de 51,5% a 64,1%, después del gasto en pauta, no antes.
- Saca la conversación el día uno. El chat fue la única función a la que la retención respondió, y llegó en la semana siete. Todo lo anterior era un motor de emparejamiento que entregaba la gente a WhatsApp.
- Sé dueño de los proveedores commodity desde el principio. Cuatro proveedores de SMS en siete configuraciones, y cuatro de moderación, me enseñaron la misma lección dos veces: guarda el estado en mi base de datos, haz del proveedor un valor de configuración.
- Pon precio para el mercado antes de comprar tráfico. $15/mes en un mercado de $5/mes desperdició casi todos los $800. El plan semanal que sí encaja en Colombia salió un mes tarde.
- Nunca metas un supuesto demográfico en el esquema. El género estaba en las rutas, los textos y las tablas a la vez, así que reemplazarlo por un rol costó una semana en vez de un día.
- Compra atribución antes de comprar pauta. source: unknown, 2.026 filas.