docs: contrato real de la API v1 para el equipo integrador

Responde las 10 preguntas que mandaron sobre admin.u-site.app leyendo el
código del servidor en vez de inferirlas desde el cliente, con cita de
archivo y línea en cada una.

Lo que sale de ahí:

- expires_in sí es timestamp absoluto (su lectura era correcta), pero el
  token se emite con 90 años de vigencia porque Login no pasa vencimiento.
- La API v1 NO acepta Bearer: autentica solo por la cookie
  Verify-Rest-Token, así que el placeholder "..." sin completar deja los 12
  endpoints en 401. Es la causa más probable de que la integración nunca
  haya autenticado por sí misma.
- Hay DOS textos de error 401 ("Token not found" y "Invalid Attempt"), y su
  reintento solo cubre el segundo.
- El envoltorio doble-codificado de dLocal es real, nadie lo adivinó mal.
- generate-qr devuelve imagen binaria, no JSON.

Incluye los pendientes de nuestro lado (vigencia del token, Secure=false en
la cookie, auth por cookie en una API máquina-a-máquina) y la recomendación
de usar /api/v1/pagos-externos, que ya usa Bearer + IP, para integraciones
nuevas.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-08-15 02:02:59 -05:00
co-authored by Claude Sonnet 5
parent 8ef49c5169
commit ff4237eced
2 changed files with 722 additions and 0 deletions
+505
View File
@@ -0,0 +1,505 @@
<title>Contrato API v1</title>
<style>
/* ── Tokens ───────────────────────────────────────────────────────────────
Paleta anclada al verde de marca de U-Site (#8eb02f) que ya usa el panel,
el widget y los correos. Neutros con sesgo oliva para que el acento no
flote sobre un gris ajeno. Los colores semánticos (crítico / atención /
confirmado) son un juego aparte del acento, a propósito. */
:root {
--ground: #f6f6f2;
--surface: #ffffff;
--surface-sunk: #f0f1ea;
--line: #e0e1d6;
--line-strong: #c9cbba;
--text: #1c1e18;
--text-soft: #5a5d51;
--text-faint: #86897a;
--accent: #63801c;
--accent-soft: #eef3dd;
--crit: #a32a1e;
--crit-soft: #fbe9e6;
--warn: #8a5a06;
--warn-soft: #fbf0d9;
--ok: #2f6b3f;
--ok-soft: #e6f1e6;
--code-bg: #1a1c16;
--code-text: #e8eadd;
--code-dim: #8f947f;
--shadow: 0 1px 2px rgba(28,30,24,.06), 0 8px 24px -12px rgba(28,30,24,.14);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--ground: #14150f;
--surface: #1c1e17;
--surface-sunk: #24261d;
--line: #32352a;
--line-strong: #474b3c;
--text: #e9ebdf;
--text-soft: #b0b4a2;
--text-faint: #82866f;
--accent: #a8c94f;
--accent-soft: #2a3118;
--crit: #f08a7c;
--crit-soft: #38201c;
--warn: #e5b45c;
--warn-soft: #362a13;
--ok: #7fc08d;
--ok-soft: #1d2f22;
--code-bg: #0e0f0a;
--code-text: #e8eadd;
--code-dim: #7b8069;
--shadow: 0 1px 2px rgba(0,0,0,.4), 0 10px 30px -14px rgba(0,0,0,.7);
}
}
:root[data-theme="dark"] {
--ground: #14150f;
--surface: #1c1e17;
--surface-sunk: #24261d;
--line: #32352a;
--line-strong: #474b3c;
--text: #e9ebdf;
--text-soft: #b0b4a2;
--text-faint: #82866f;
--accent: #a8c94f;
--accent-soft: #2a3118;
--crit: #f08a7c;
--crit-soft: #38201c;
--warn: #e5b45c;
--warn-soft: #362a13;
--ok: #7fc08d;
--ok-soft: #1d2f22;
--code-bg: #0e0f0a;
--code-text: #e8eadd;
--code-dim: #7b8069;
--shadow: 0 1px 2px rgba(0,0,0,.4), 0 10px 30px -14px rgba(0,0,0,.7);
}
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
background: var(--ground);
color: var(--text);
font-family: ui-sans-serif, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
font-size: 16px;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
.wrap { max-width: 60rem; margin: 0 auto; padding: 3rem 1.5rem 6rem; }
/* ── Encabezado ─────────────────────────────────────────────────────────── */
.eyebrow {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: .6875rem; letter-spacing: .14em; text-transform: uppercase;
color: var(--accent); margin: 0 0 .75rem;
}
h1 {
font-size: clamp(1.9rem, 1.3rem + 2.2vw, 2.75rem);
line-height: 1.1; letter-spacing: -.022em; font-weight: 660;
margin: 0 0 .85rem; text-wrap: balance;
}
.standfirst {
font-size: 1.0625rem; color: var(--text-soft);
max-width: 46rem; margin: 0 0 2rem;
}
.standfirst strong { color: var(--text); font-weight: 620; }
/* ── Resumen ────────────────────────────────────────────────────────────── */
.tally { display: flex; flex-wrap: wrap; gap: .625rem; margin-bottom: 1rem; }
.tally-item {
display: flex; align-items: baseline; gap: .5rem;
background: var(--surface); border: 1px solid var(--line);
border-radius: .5rem; padding: .625rem .875rem; box-shadow: var(--shadow);
}
.tally-n {
font-size: 1.375rem; font-weight: 680; line-height: 1;
font-variant-numeric: tabular-nums;
}
.tally-l { font-size: .8125rem; color: var(--text-soft); }
.tally-item.is-crit { border-color: var(--crit); }
.tally-item.is-crit .tally-n { color: var(--crit); }
.tally-item.is-warn .tally-n { color: var(--warn); }
.tally-item.is-ok .tally-n { color: var(--ok); }
.meta {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: .75rem; color: var(--text-faint);
border-top: 1px solid var(--line); padding-top: 1rem; margin-bottom: 2.75rem;
}
.meta code { background: none; padding: 0; color: var(--text-soft); }
/* ── Secciones numeradas ────────────────────────────────────────────────
La numeración no es decorativa: el otro equipo mandó las preguntas
numeradas del 1 al 10 y así se responden en el mismo orden. */
.q {
display: grid; grid-template-columns: 3.25rem 1fr; gap: 0 1.25rem;
padding: 1.75rem 0; border-top: 1px solid var(--line);
}
.q-num {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: .875rem; font-weight: 600; color: var(--text-faint);
font-variant-numeric: tabular-nums; padding-top: .3rem;
}
.q-body { min-width: 0; }
.q h2 {
font-size: 1.1875rem; line-height: 1.3; letter-spacing: -.012em;
font-weight: 640; margin: 0 0 .6rem; text-wrap: balance;
}
.q p { margin: 0 0 .85rem; }
.q p:last-child { margin-bottom: 0; }
.q ul { margin: 0 0 .85rem; padding-left: 1.15rem; }
.q li { margin-bottom: .3rem; }
/* Barra de severidad a la izquierda del número */
.q.sev-crit .q-num { color: var(--crit); }
.q.sev-warn .q-num { color: var(--warn); }
.q.sev-ok .q-num { color: var(--ok); }
.chip {
display: inline-block; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: .6875rem; letter-spacing: .06em; text-transform: uppercase;
padding: .2rem .5rem; border-radius: .3125rem; margin-bottom: .55rem;
font-weight: 600;
}
.chip-crit { background: var(--crit-soft); color: var(--crit); }
.chip-warn { background: var(--warn-soft); color: var(--warn); }
.chip-ok { background: var(--ok-soft); color: var(--ok); }
.chip-info { background: var(--surface-sunk); color: var(--text-soft); }
/* ── Código ─────────────────────────────────────────────────────────────── */
pre {
background: var(--code-bg); color: var(--code-text);
border-radius: .5rem; padding: .875rem 1rem; margin: 0 0 .85rem;
overflow-x: auto; font-size: .8125rem; line-height: 1.55;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
pre .c { color: var(--code-dim); }
pre .hl { color: #d9e88a; }
code {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: .875em; background: var(--surface-sunk);
padding: .1rem .3rem; border-radius: .25rem;
}
.cite {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: .75rem; color: var(--text-faint); margin: -.35rem 0 .85rem;
}
.note {
border-left: 2px solid var(--accent); background: var(--accent-soft);
padding: .75rem .9rem; border-radius: 0 .375rem .375rem 0;
margin: 0 0 .85rem; font-size: .9375rem;
}
.note.is-crit { border-left-color: var(--crit); background: var(--crit-soft); }
.note p { margin: 0; }
.note p + p { margin-top: .5rem; }
/* ── Tablas ─────────────────────────────────────────────────────────────── */
.scroll { overflow-x: auto; margin: 0 0 .85rem; }
table { border-collapse: collapse; width: 100%; font-size: .875rem; min-width: 32rem; }
th, td { text-align: left; padding: .5rem .7rem; border-bottom: 1px solid var(--line); vertical-align: top; }
th {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: .6875rem; letter-spacing: .07em; text-transform: uppercase;
color: var(--text-faint); font-weight: 600;
border-bottom-color: var(--line-strong);
}
td code { font-size: .8125rem; }
/* ── Cierre ─────────────────────────────────────────────────────────────── */
.todo {
margin-top: 3rem; background: var(--surface); border: 1px solid var(--line);
border-radius: .625rem; padding: 1.5rem 1.75rem; box-shadow: var(--shadow);
}
.todo h2 { font-size: 1.125rem; font-weight: 640; margin: 0 0 .35rem; letter-spacing: -.01em; }
.todo > p { color: var(--text-soft); font-size: .9375rem; margin: 0 0 1.1rem; }
.todo ol { margin: 0; padding-left: 1.15rem; }
.todo li { margin-bottom: .7rem; }
.todo li:last-child { margin-bottom: 0; }
.todo li strong { font-weight: 620; }
a { color: var(--accent); text-underline-offset: 2px; }
a:focus-visible, [tabindex]:focus-visible {
outline: 2px solid var(--accent); outline-offset: 2px; border-radius: .2rem;
}
@media (max-width: 34rem) {
.q { grid-template-columns: 1fr; gap: 0; }
.q-num { padding-top: 0; margin-bottom: .35rem; }
}
</style>
<div class="wrap">
<p class="eyebrow">Referencia de integración · admin.u-site.app</p>
<h1>Contrato real de la API v1</h1>
<p class="standfirst">
Las 10 preguntas del equipo integrador, respondidas <strong>leyendo el código del
servidor</strong> 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ó.
</p>
<div class="tally">
<div class="tally-item is-crit"><span class="tally-n">1</span><span class="tally-l">crítica</span></div>
<div class="tally-item is-warn"><span class="tally-n">2</span><span class="tally-l">requieren atención</span></div>
<div class="tally-item is-ok"><span class="tally-n">7</span><span class="tally-l">contrato confirmado</span></div>
<div class="tally-item"><span class="tally-n">4</span><span class="tally-l">arreglos de nuestro lado</span></div>
</div>
<p class="meta">
Base de todos los endpoints: <code>https://admin.u-site.app/api/v1/…</code><br>
Montaje: <code>rest/routes/routes.go:10</code> monta <code>/api</code> · <code>rest/routes/api.go:38</code> agrega <code>v1</code>
</p>
<!-- 1 -->
<section class="q sev-ok">
<div class="q-num">01</div>
<div class="q-body">
<span class="chip chip-ok">Su lectura es correcta</span>
<h2><code>expires_in</code> es un timestamp Unix absoluto</h2>
<p>El campo se reusa: entra como duración en segundos y sale como timestamp.</p>
<pre><span class="c">// config/token.go:26-49</span>
t.Expire = ninetyYears <span class="c">// duración, en segundos</span>
expiresIn := time.Now().Add(
time.Duration(t.Expire)*time.Second).Unix()
claims["exp"] = expiresIn
t.Expire = <span class="hl">expiresIn</span> <span class="c">// ← se SOBREESCRIBE</span></pre>
<p>Lo que viaja en la respuesta es <code>expiresIn</code>: segundos desde epoch.</p>
<div class="note is-crit">
<p><strong>Pero hay algo más grave.</strong> <code>Login</code> llama a
<code>CreateToken</code> sin pasarle vencimiento (<code>pkg/auth/user.go:98</code>),
así que aplica el default: <strong>90 años</strong>.</p>
<p>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.</p>
</div>
</div>
</section>
<!-- 2 -->
<section class="q sev-ok">
<div class="q-num">02</div>
<div class="q-body">
<span class="chip chip-ok">Contrato confirmado</span>
<h2>Respuesta de <code>POST /oauth/token</code></h2>
<pre>{
"<span class="hl">token</span>": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"<span class="hl">expires_in</span>": 4626547200
}</pre>
<p class="cite">rest/controllers/api/auth_controller.go:42-45</p>
<ul>
<li>El campo es <code>token</code>, <strong>no</strong> <code>access_token</code>.</li>
<li><code>expires_in</code> está en el nivel raíz.</li>
<li>No hay <code>token_type</code>, ni <code>refresh_token</code>, ni envoltorio.</li>
</ul>
<p>Error de credenciales, siempre HTTP 401:</p>
<pre>{ "error": true, "message": "Invalid Credentials" }</pre>
</div>
</section>
<!-- 3 -->
<section class="q sev-warn">
<div class="q-num">03</div>
<div class="q-body">
<span class="chip chip-warn">Plano, con una excepción</span>
<h2>QR y VCF devuelven JSON sin envoltorio</h2>
<div class="scroll">
<table>
<thead>
<tr><th>Endpoint</th><th>Método</th><th>Respuesta</th></tr>
</thead>
<tbody>
<tr><td><code>/generate-qr-tmp</code></td><td>POST</td><td><code>{"url": "https://…"}</code></td></tr>
<tr><td><code>/generate-url-qr</code></td><td>POST</td><td><code>{"url": "https://…"}</code></td></tr>
<tr><td><code>/generate-vcf</code></td><td>POST</td><td><code>{"success": true, "url": "https://…"}</code></td></tr>
<tr><td><code>/generate-qr</code></td><td>POST</td><td><strong>No es JSON</strong> — imagen binaria <code>image/webp</code></td></tr>
</tbody>
</table>
</div>
<p class="cite">vcard_qr.go:133-135 · vcard_vcf.go:103-106 · vcard_qr.go:169-170</p>
<p>Ojo con el último: <code>generate-qr</code> responde bytes de imagen. Un
<code>json_decode</code> sobre eso falla siempre.</p>
<p>Errores: <code>{"error": "Datos inválidos"}</code> con 400, o
<code>{"error": "&lt;detalle&gt;"}</code> con 500.</p>
</div>
</section>
<!-- 4 -->
<section class="q sev-ok">
<div class="q-num">04</div>
<div class="q-body">
<span class="chip chip-ok">Sí, el envoltorio raro es real</span>
<h2>dLocal devuelve JSON codificado como string</h2>
<p>Nadie lo adivinó mal. La doble codificación existe:</p>
<pre><span class="c">// rest/controllers/api/dlocal_controller.go:42-45</span>
return c.Status(200).JSON(fiber.Map{
"message": "Plan creado exitosamente.",
"response": <span class="hl">string(response)</span>, <span class="c">// ← el JSON, como STRING</span>
})</pre>
<p>Lo que llega:</p>
<pre>{
"message": "Plan creado exitosamente.",
"response": "<span class="hl">{\"id\":\"PLAN-123\",\"status\":\"active\"}</span>"
}</pre>
<p>Hay que hacer <code>json_decode</code> <strong>dos veces</strong>: una del cuerpo y
otra del campo <code>response</code>. Aplica igual a los 7 endpoints dLocal
(líneas 44, 77, 110, 137, 162, 188, 221).</p>
<p>El texto de <code>message</code> cambia según el endpoint, así que no conviene
usarlo para decidir nada.</p>
</div>
</section>
<!-- 5 -->
<section class="q sev-warn">
<div class="q-num">05</div>
<div class="q-body">
<span class="chip chip-warn">Hay dos textos, no uno</span>
<h2>El reintento automático cubre solo la mitad de los casos</h2>
<pre><span class="c">// rest/middlewares/auth.go:271-286</span>
token := c.Cookies("Verify-Rest-Token")
if token == "" {
return c.Status(401).JSON(<span class="hl">"Token not found"</span>) <span class="c">// falta la cookie</span>
}
… ErrorHandler:
return ctx.Status(401).JSON(<span class="hl">"Invalid Attempt"</span>) <span class="c">// cookie inválida o vencida</span></pre>
<p>Los dos son <strong>JSON string, con comillas incluidas</strong> — el cuerpo
literal es <code>"Invalid Attempt"</code>, 16 bytes, no el texto pelado.</p>
<div class="note">
<p><strong>Recomendación:</strong> reintentar ante cualquier 401, sin mirar el
texto. Es más robusto y no depende de una redacción que puede cambiar.</p>
</div>
</div>
</section>
<!-- 6 -->
<section class="q sev-ok">
<div class="q-num">06</div>
<div class="q-body">
<span class="chip chip-ok">Una sola raíz</span>
<h2>No hay hosts separados por grupo</h2>
<p>Todo cuelga de <code>https://admin.u-site.app/api/v1/</code>. Lo correcto es
guardar <strong>la raíz</strong> y concatenar la ruta, en vez de guardar la URL del
token y derivar las demás con <code>str_replace</code>.</p>
<pre>POST /api/v1/oauth/token
POST /api/v1/generate-qr-tmp
POST /api/v1/generate-qr <span class="c">(imagen)</span>
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</pre>
</div>
</section>
<!-- 7 -->
<section class="q sev-crit">
<div class="q-num">07</div>
<div class="q-body">
<span class="chip chip-crit">Crítico</span>
<h2>El header <code>Cookie</code> es obligatorio, y hoy está roto</h2>
<p>La API <strong>no acepta Bearer</strong>. Autentica exclusivamente por cookie:</p>
<pre><span class="c">// rest/middlewares/auth.go:272-281</span>
token := c.Cookies(<span class="hl">"Verify-Rest-Token"</span>)
if token == "" { return c.Status(401).JSON("Token not found") }
TokenLookup: <span class="hl">"cookie:Verify-Rest-Token"</span></pre>
<div class="note is-crit">
<p>Con el placeholder literal <code>...</code> sin completar, <strong>los 12
endpoints autenticados devuelven 401</strong>. Si algo funciona hoy, es porque el
cliente HTTP tiene cookie jar y reusa la cookie que <code>POST /oauth/token</code>
deja seteada en la respuesta (<code>config/token.go:40-47</code>).</p>
</div>
<ul>
<li>Mandar el token en el body o como <code>Authorization: Bearer</code>
<strong>no autentica nada</strong>.</li>
<li><code>session_id</code> <strong>no hace falta</strong>: el middleware no lo
mira. Se puede quitar.</li>
</ul>
<p>Lo correcto: tomar el <code>token</code> de la respuesta de <code>oauth/token</code>
y mandarlo como <code>Cookie: Verify-Rest-Token=&lt;token&gt;</code>, o dejar que el
cliente maneje cookies solo.</p>
</div>
</section>
<!-- 8 -->
<section class="q sev-ok">
<div class="q-num">08</div>
<div class="q-body">
<span class="chip chip-info">No aplica</span>
<h2>No hay control por IP en <code>/api/v1</code></h2>
<p>Solo se valida la cookie JWT. No hay allowlist; no hace falta registrar la IP
del servidor de producción.</p>
<p>Sí existe validación por IP, pero en <strong>otra</strong> API:
<code>/api/v2</code> usa <code>ApiKey</code> con IP obligatoria y scopes, y
<code>/api/v1/pagos-externos</code> usa <code>AuthServicioPago</code> — ambas
excluidas de <code>AuthApi</code> (<code>auth.go:266-268</code>).</p>
<div class="note">
<p>Si prefieren un esquema con IP fija y token que no viva en una cookie,
<strong><code>/api/v2</code> es el camino</strong>, y conviene migrar ahí en vez de
arreglar el actual.</p>
</div>
</div>
</section>
<!-- 9 -->
<section class="q sev-warn">
<div class="q-num">09</div>
<div class="q-body">
<span class="chip chip-warn">Fuera de este documento</span>
<h2>Credenciales</h2>
<p>No van acá. Son las de un usuario real de la tabla <code>users</code>
(<code>login.CheckLogin()</code>, <code>auth_controller.go:28</code>). Que se
entreguen por un canal seguro.</p>
<p>Dado que llevan 15 meses sin rotar, conviene <strong>crear un usuario dedicado a
la integración</strong> en vez de reusar el de una persona.</p>
</div>
</section>
<!-- 10 -->
<section class="q sev-ok">
<div class="q-num">10</div>
<div class="q-body">
<span class="chip chip-info">Existen, pero no para esto</span>
<h2>No hay callback saliente hacia ustedes</h2>
<p>Hay webhooks entrantes de pago ya montados
(<code>rest/routes/publicas.go:22-33</code>): <code>/webhooks/bold</code>,
<code>/webhooks/dlocal</code>, <code>/webhooks/paypal</code>, más Coolify y Telegram.</p>
<p>Pero sirven para que <strong>la pasarela nos avise a nosotros</strong>, no para
avisarle a un tercero. Un callback hacia su sistema es desarrollo nuevo.</p>
<p>Mientras tanto, la alternativa ya construida es
<code>/api/v1/pagos-externos</code>, pensada justo para apps de terceros:</p>
<pre>GET /api/v1/pagos-externos/pasarelas
POST /api/v1/pagos-externos/solicitar
GET /api/v1/pagos-externos/:referencia/estado</pre>
<div class="note">
<p>Usa token Bearer + IP (no cookie), así que evita todo el problema del punto 07.
<strong>Para una integración nueva, recomendamos esta</strong> y no los endpoints
dLocal directos.</p>
</div>
</div>
</section>
<div class="todo">
<h2>Pendientes de nuestro lado</h2>
<p>No son preguntas: son cosas a corregir en <code>admin.u-site.app</code>.</p>
<ol>
<li><strong>Tokens de 90 años</strong> (<code>config/token.go:26</code>). 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.</li>
<li><strong>La cookie se emite con <code>Secure: false</code></strong>
(<code>config/token.go:44</code>), así que viaja también por HTTP plano.</li>
<li><strong>Autenticación por cookie en una API máquina-a-máquina</strong> es el
problema de fondo: obliga a manejar cookie jar y no permite allowlist por IP.
<code>/api/v2</code> ya lo resuelve bien.</li>
<li><strong>La doble codificación de dLocal</strong> no aporta nada; devolver el
JSON anidado directo sería un cambio compatible si se versiona.</li>
</ol>
</div>
</div>
+217
View File
@@ -0,0 +1,217 @@
# Contrato real de la API v1 de `admin.u-site.app`
Respuestas verificadas **leyendo el código del servidor**, no inferidas desde
el cliente. Cada punto cita el archivo y la línea para que se pueda auditar.
Base de todos los endpoints: `https://admin.u-site.app/api/v1/…`
(`rest/routes/routes.go:10` monta `/api`, `rest/routes/api.go:38` agrega `v1`).
---
## 1. Formato de `expires_in` → **timestamp Unix absoluto**
Su interpretación es la correcta. En `config/token.go:22-49`:
```go
t.Expire = ninetyYears // acá es duración en segundos
expiresIn := time.Now().Add(time.Duration(t.Expire)*time.Second).Unix()
claims["exp"] = expiresIn
t.Expire = expiresIn // ← se SOBREESCRIBE con el absoluto
```
El campo se reusa: entra como duración y sale como timestamp. Lo que viaja en
la respuesta es `expiresIn`, o sea **segundos desde epoch**.
> **Pero hay algo más importante:** cuando `Login` llama a `CreateToken` no le
> pasa vencimiento (`pkg/auth/user.go:98`), así que aplica el default de
> `ninetyYears` — **90 años**. En la práctica el token de esa integración
> nunca expira, y toda la lógica de caché y renovación no se ejerce jamás.
> Esto hay que cambiarlo del lado nuestro (ver Pendientes).
## 2. Respuesta real de `POST /api/v1/oauth/token`
`rest/controllers/api/auth_controller.go:42-45`:
```json
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"expires_in": 4626547200
}
```
- El campo es **`token`**, no `access_token`.
- `expires_in` está en el nivel raíz.
- No hay `token_type`, ni `refresh_token`, ni envoltorio.
Errores (todos con HTTP 401), `auth_controller.go:15-40`:
```json
{ "error": true, "message": "Invalid Credentials" }
```
## 3. QR y VCF → **JSON plano**
| Endpoint | Método | Respuesta |
|---|---|---|
| `/api/v1/generate-qr-tmp` | POST | `{"url": "https://…"}` |
| `/api/v1/generate-url-qr` | POST | `{"url": "https://…"}` |
| `/api/v1/generate-vcf` | POST | `{"success": true, "url": "https://…"}` |
| `/api/v1/generate-qr` | POST | **no es JSON**: devuelve la imagen binaria `Content-Type: 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, no JSON. Si el
cliente hace `json_decode` de eso, falla siempre.
Errores: `{"error": "Datos inválidos"}` con 400, o `{"error": "<detalle>"}` con 500.
## 4. dLocal → **sí, el envoltorio raro es real**
Nadie lo adivinó mal. `rest/controllers/api/dlocal_controller.go:42-45`:
```go
return c.Status(200).JSON(fiber.Map{
"message": "Plan creado exitosamente.",
"response": string(response), // ← el JSON de dLocal, como STRING
})
```
O sea, doble codificación real:
```json
{
"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 campo `message` cambia de texto según el endpoint, así que
no conviene usarlo para decidir nada.
## 5. Cuerpo de error de sesión inválida → **hay DOS textos distintos**
Acá está el problema que sospechaban. `rest/middlewares/auth.go:271-286`:
```go
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/vencida
```
- Sin cookie → cuerpo `"Token not found"`
- Cookie presente pero inválida o vencida → cuerpo `"Invalid Attempt"`
Los dos son **JSON string, con comillas incluidas** — el cuerpo literal es
`"Invalid Attempt"`, 16 bytes, no el texto pelado.
Su reintento solo cubre el segundo caso. **Recomendación: reintentar ante
cualquier 401**, sin mirar el texto. Es más robusto y no depende de una
redacción que puede cambiar.
## 6. URLs base → **una sola raíz, sin derivar por `str_replace`**
Todos cuelgan de `https://admin.u-site.app/api/v1/`. No hay hosts separados
por grupo, así que lo correcto es guardar **la raíz** y concatenar la ruta,
no guardar la URL del token y derivar las demás:
```
POST /api/v1/oauth/token
POST /api/v1/generate-qr-tmp
POST /api/v1/generate-qr (devuelve 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
```
## 7. El header `Cookie` → **es OBLIGATORIO, y hoy está roto**
Esta es la más crítica de las 10.
La API **no acepta Bearer**. `AuthApi()` lee exclusivamente la cookie
(`rest/middlewares/auth.go:272-281`):
```go
token := c.Cookies("Verify-Rest-Token")
if token == "" { return c.Status(401).JSON("Token not found") }
TokenLookup: "cookie:Verify-Rest-Token"
```
Consecuencias:
- Mandar el token en el body o como `Authorization: Bearer` **no autentica nada**.
- Con el placeholder literal `...` sin completar, **los 12 endpoints
autenticados devuelven 401 "Token not found"**. Si algo de eso "funciona"
hoy, es porque el cliente HTTP tiene cookie jar y está reusando la cookie
que `POST /oauth/token` deja seteada en la respuesta (`config/token.go:40-47`).
- **`session_id` no hace falta**: el middleware no lo mira. Se puede quitar.
Lo correcto es 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.
## 8. Control de acceso por IP → **no, para `/api/v1` no hay**
`/api/v1` solo valida la cookie JWT. No hay allowlist de IP; 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` en `auth.go:266-268`). Si prefieren un esquema
con IP fija y token que no vive en una cookie, **`/api/v2` es el camino** y
vale la pena migrar ahí en vez de arreglar el actual.
## 9. Usuario y contraseña
No van en este documento. Son las credenciales de un usuario real de la tabla
`users` (`login.CheckLogin()`, `auth_controller.go:28`). Que las entreguen por
un canal seguro y, dado que llevan 15 meses sin rotar, **conviene crear un
usuario dedicado a la integración** en vez de reusar uno de persona.
## 10. Webhooks → **sí existen, pero no para esto**
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 son para que **la pasarela nos avise a nosotros**, no para avisarle a un
tercero. Hoy **no existe** un callback saliente hacia el sistema de ustedes ni
para pagos ni para QR. Si lo necesitan, es desarrollo nuevo de nuestro lado.
Mientras tanto, la alternativa ya construida es `/api/v1/pagos-externos`
(`api.go:32-34`), 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 7.
**Para una integración nueva, recomendamos esta y no los endpoints dLocal
directos.**
---
## Pendientes de nuestro lado (no son preguntas, son cosas a corregir)
1. **Tokens de 90 años** (`config/token.go:26`). Un token filtrado es acceso
permanente. Hay que 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 IP allowlist. `/api/v2` ya
resuelve esto bien.
4. **La doble codificación de dLocal** (punto 4) no aporta nada; devolver el
JSON anidado directo sería un cambio compatible si se versiona.