Referencia de integración · admin.u-site.app

# Contrato real de la API v1

Las 10 preguntas del equipo integrador, respondidas leyendo el código del servidor en vez de inferirlas desde el cliente. Cada respuesta cita archivo y línea para que se pueda auditar. Una es crítica y explica por qué la integración probablemente nunca autenticó.

1 crítica
2 requieren atención
7 contrato confirmado
4 arreglos de nuestro lado

Base de todos los endpoints: `https://admin.u-site.app/api/v1/…`
Montaje: `rest/routes/routes.go:10` monta `/api` · `rest/routes/api.go:38` agrega `v1`

01
Su lectura es correcta

## `expires_in` es un timestamp Unix absoluto

El campo se reusa: entra como duración en segundos y sale como timestamp.

    // config/token.go:26-49
    t.Expire = ninetyYears                    // duración, en segundos
    expiresIn := time.Now().Add(
        time.Duration(t.Expire)*time.Second).Unix()
    claims["exp"] = expiresIn
    t.Expire = expiresIn                     // ← se SOBREESCRIBE

Lo que viaja en la respuesta es `expiresIn`: segundos desde epoch.

Pero hay algo más grave. `Login` llama a `CreateToken` sin pasarle vencimiento (`pkg/auth/user.go:98`), así que aplica el default: 90 años.

En la práctica ese token nunca expira, y toda la lógica de caché y renovación del cliente no se ejerce jamás. Es un arreglo nuestro, no de ustedes.

02
Contrato confirmado

## Respuesta de `POST /oauth/token`

    {
      "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
      "expires_in": 4626547200
    }

rest/controllers/api/auth_controller.go:42-45

- El campo es `token`, no `access_token`.
- `expires_in` está en el nivel raíz.
- No hay `token_type`, ni `refresh_token`, ni envoltorio.

Error de credenciales, siempre HTTP 401:

    { "error": true, "message": "Invalid Credentials" }

03
Plano, con una excepción

## QR y VCF devuelven JSON sin envoltorio

Endpoint | Método | Respuesta
--- | --- | ---
`/generate-qr-tmp` | POST | `{"url": "https://…"}`
`/generate-url-qr` | POST | `{"url": "https://…"}`
`/generate-vcf` | POST | `{"success": true, "url": "https://…"}`
`/generate-qr` | POST | No es JSON — imagen binaria `image/webp`

vcard_qr.go:133-135 · vcard_vcf.go:103-106 · vcard_qr.go:169-170

Ojo con el último: `generate-qr` responde bytes de imagen. Un `json_decode` sobre eso falla siempre.

Errores: `{"error": "Datos inválidos"}` con 400, o `{"error": "<detalle>"}` con 500.

04
Sí, el envoltorio raro es real

## dLocal devuelve JSON codificado como string

Nadie lo adivinó mal. La doble codificación existe:

    // rest/controllers/api/dlocal_controller.go:42-45
    return c.Status(200).JSON(fiber.Map{
        "message":  "Plan creado exitosamente.",
        "response": string(response),   // ← el JSON, como STRING
    })

Lo que llega:

    {
      "message": "Plan creado exitosamente.",
      "response": "{\"id\":\"PLAN-123\",\"status\":\"active\"}"
    }

Hay que hacer `json_decode` dos veces: una del cuerpo y otra del campo `response`. Aplica igual a los 7 endpoints dLocal (líneas 44, 77, 110, 137, 162, 188, 221).

El texto de `message` cambia según el endpoint, así que no conviene usarlo para decidir nada.

05
Hay dos textos, no uno

## El reintento automático cubre solo la mitad de los casos

    // rest/middlewares/auth.go:271-286
    token := c.Cookies("Verify-Rest-Token")
    if token == "" {
        return c.Status(401).JSON("Token not found")   // falta la cookie
    }
    … ErrorHandler:
        return ctx.Status(401).JSON("Invalid Attempt")  // cookie inválida o vencida

Los dos son JSON string, con comillas incluidas — el cuerpo literal es `"Invalid Attempt"`, 16 bytes, no el texto pelado.

Recomendación: reintentar ante cualquier 401, sin mirar el texto. Es más robusto y no depende de una redacción que puede cambiar.

06
Una sola raíz

## No hay hosts separados por grupo

Todo cuelga de `https://admin.u-site.app/api/v1/`. Lo correcto es guardar la raíz y concatenar la ruta, en vez de guardar la URL del token y derivar las demás con `str_replace`.

    POST   /api/v1/oauth/token
    POST   /api/v1/generate-qr-tmp
    POST   /api/v1/generate-qr                     (imagen)
    POST   /api/v1/generate-vcf
    POST   /api/v1/generate-url-qr
    POST   /api/v1/dlocal/subscription/crear-plan
    GET    /api/v1/dlocal/subscription/ver-plan/:planID
    PATCH  /api/v1/dlocal/subscription/actualizar-plan/:planID
    GET    /api/v1/dlocal/subscription/plan/all
    PATCH  /api/v1/dlocal/subscription/plan/:planId/subscription/:subscriptionId/deactivate
    GET    /api/v1/dlocal/subscription/:subscriptionId/execution/:invoiceId
    POST   /api/v1/dlocal/payment/crear-pago
    POST   /api/v1/rapyd/wallet/create

07
Crítico

## El header `Cookie` es obligatorio, y hoy está roto

La API no acepta Bearer. Autentica exclusivamente por cookie:

    // rest/middlewares/auth.go:272-281
    token := c.Cookies("Verify-Rest-Token")
    if token == "" { return c.Status(401).JSON("Token not found") }
    TokenLookup: "cookie:Verify-Rest-Token"

Con el placeholder literal `...` sin completar, los 12 endpoints autenticados devuelven 401. Si algo funciona hoy, es porque el cliente HTTP tiene cookie jar y reusa la cookie que `POST /oauth/token` deja seteada en la respuesta (`config/token.go:40-47`).

- Mandar el token en el body o como `Authorization: Bearer` no autentica nada.
- `session_id` no hace falta: el middleware no lo mira. Se puede quitar.

Lo correcto: tomar el `token` de la respuesta de `oauth/token` y mandarlo como `Cookie: Verify-Rest-Token=<token>`, o dejar que el cliente maneje cookies solo.

08
No aplica

## No hay control por IP en `/api/v1`

Solo se valida la cookie JWT. No hay allowlist; no hace falta registrar la IP del servidor de producción.

Sí existe validación por IP, pero en otra API: `/api/v2` usa `ApiKey` con IP obligatoria y scopes, y `/api/v1/pagos-externos` usa `AuthServicioPago` — ambas excluidas de `AuthApi` (`auth.go:266-268`).

Si prefieren un esquema con IP fija y token que no viva en una cookie, `/api/v2` es el camino, y conviene migrar ahí en vez de arreglar el actual.

09
Fuera de este documento

## Credenciales

No van acá. Son las de un usuario real de la tabla `users` (`login.CheckLogin()`, `auth_controller.go:28`). Que se entreguen por un canal seguro.

Dado que llevan 15 meses sin rotar, conviene crear un usuario dedicado a la integración en vez de reusar el de una persona.

10
Existen, pero no para esto

## No hay callback saliente hacia ustedes

Hay webhooks entrantes de pago ya montados (`rest/routes/publicas.go:22-33`): `/webhooks/bold`, `/webhooks/dlocal`, `/webhooks/paypal`, más Coolify y Telegram.

Pero sirven para que la pasarela nos avise a nosotros, no para avisarle a un tercero. Un callback hacia su sistema es desarrollo nuevo.

Mientras tanto, la alternativa ya construida es `/api/v1/pagos-externos`, pensada justo para apps de terceros:

    GET  /api/v1/pagos-externos/pasarelas
    POST /api/v1/pagos-externos/solicitar
    GET  /api/v1/pagos-externos/:referencia/estado

Usa token Bearer + IP (no cookie), así que evita todo el problema del punto 07. Para una integración nueva, recomendamos esta y no los endpoints dLocal directos.

## Pendientes de nuestro lado

No son preguntas: son cosas a corregir en `admin.u-site.app`.

1. Tokens de 90 años (`config/token.go:26`). Un token filtrado es acceso permanente. Ponerle un vencimiento razonable — y recién ahí la caché y el reintento del cliente van a tener sentido.
2. La cookie se emite con `Secure: false` (`config/token.go:44`), así que viaja también por HTTP plano.
3. Autenticación por cookie en una API máquina-a-máquina es el problema de fondo: obliga a manejar cookie jar y no permite allowlist por IP. `/api/v2` ya lo resuelve bien.
4. La doble codificación de dLocal no aporta nada; devolver el JSON anidado directo sería un cambio compatible si se versiona.