Files
soft_usite/docs/api-v1-contrato.html
T
Lizandro GuarnizoandClaude Sonnet 5 ff4237eced 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>
2026-08-15 02:02:59 -05:00

506 lines
22 KiB
HTML

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