~/henry-cabello
CVEnglish
← volver a ./work
En producción

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

Google Play · 5,000+ descargas ↗luxura.vip ↗

Formulario de creación de perfil de Luxura, paso uno del onboardingGrilla de Luxura con perfiles verificados y el contador diario de invitacionesPantalla de conexiones de Luxura con los matches aceptados
Ficha de la tienda, en español. Onboarding, la grilla de perfiles con el contador diario, y la pantalla de conexiones.

$ 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

3,709Registros
1,724Aprobados
3,983Invitaciones
888Conexiones
15,480Mensajes
2Pagando

$ ./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ó.

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%.

El muro se movió en vez de desaparecer.

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

Cambié un bundle WASM de 10MB por uno WebGL de 1,4MB, y dejé los dos motores detrás de un flag

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
Puse a los servicios caros a discutir con uno barato en vez de creerles

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.

Metí FFmpeg en la imagen de Docker en vez de alquilar un servicio de transcodificación

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.

Acepté un servidor Node propio, y con él una salida permanente de serverless

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
Dejé de buscar proveedor y construí la costura

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.

$800Gasto en pauta
$0.33Por registro
$0.70Por aprobado
0.08%Conversión a pago

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.

Cada usuario del reporte de marzo quedó marcado como source: unknown

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

1,326Commits
177Rutas API
77Páginas
44Modelos Prisma
75Migraciones
37Jobs
26Secciones admin

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.

Anuncio de Luxura para la audiencia de hombres: una mujer en una calle de Bogotá al atardecer bajo la insignia de en línea de la appAnuncio de Luxura para la audiencia de mujeres: la bandeja de invitaciones con tres pendientes y su cuenta regresivaAnuncio de Luxura para la audiencia LGBT: dos hombres riendo juntos en un bar
Tres de los sets que corrieron. Uno por audiencia, misma plantilla, cada uno en claro y oscuro, estático y video, en formato pantalla completa, banner, historia y bandeja. Los nombres de archivo llevan el segmento, que es como después podía saber qué corte estaba funcionando.

$ 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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. Compra atribución antes de comprar pauta. source: unknown, 2.026 filas.

← volver a ./work