Endpoints API
Backend: tcgcards-api corriendo en Cloud Run. Todos los endpoints REST. Prefix: /api/v1.
URL prod: https://tcgcards-api-1033181994095.us-central1.run.app
Autenticación
Sección titulada «Autenticación»Hay 4 tipos de auth distintos según el endpoint:
| Tipo | Cómo se envía | Quién la usa |
|---|---|---|
none | — | Endpoints públicos (catálogo, feeds) |
user JWT | Authorization: Bearer <jwt> | Login con Google → JWT propio firmado HS256 |
admin | user JWT + user.isAdmin === true | Solo admins del marketplace |
admin token | X-Admin-Token: <token> | Endpoints de catálogo sync — usa ADMIN_TOKEN env var |
internal auth | X-Internal-Auth: <secret> | Server-to-server (Next.js → API). Usa INTERNAL_AUTH_SECRET |
internal cron | X-Internal-Cron-Secret: <secret> | Solo Cloud Scheduler. Usa INTERNAL_CRON_SECRET |
Catálogo (público)
Sección titulada «Catálogo (público)»| Método | Path | Auth | Descripción |
|---|---|---|---|
| GET | /api/v1/health | none | Healthcheck (devuelve status de conexión Mongo) |
| GET | /api/v1/tcgs | none | Lista TCGs habilitados |
| GET | /api/v1/tcgs/:slug | none | TCG específico |
| GET | /api/v1/tcgs/:slug/rarities | none | Rarezas disponibles |
| GET | /api/v1/sets | none | Lista paginada ?tcg= |
| GET | /api/v1/sets/:id | none | Set específico |
| GET | /api/v1/cards | none | Busca/filtra cards con facetas |
| GET | /api/v1/cards/suggest | none | Autocomplete |
| GET | /api/v1/cards/counts | none | Conteo de matches por TCG para ?q= (1 consulta) |
| GET | /api/v1/cards/:id | none | Card específica |
| GET | /api/v1/cards/:id/listings | none | Listings activos de esa card |
| GET | /api/v1/cards/:id/recent-sales | none | Últimas 5 ventas |
| GET | /api/v1/sitemap/cards | none | Cards para sitemap XML |
| GET | /api/v1/sitemap/users | none | Usernames públicos para sitemap |
Filtros por TCG (atributos)
Sección titulada «Filtros por TCG (atributos)»GET /api/v1/cards (y los feeds de listings tipo card, ej. /api/v1/listings/cards) aceptan filtros de atributo declarativos según el TCG, además de los conocidos (tcg, set, rarity, q, inStock, page, limit). Cada TCG define sus propios filtros, label y path en el registro src/services/tcgFilters.ts (ej. Pokémon: energyType, stage; One Piece: color, cardType).
- Query: cada filtro es su propio parámetro con valores separados por coma → OR dentro del mismo filtro, AND entre filtros distintos. Ej:
?tcg=pokemon&energyType=Fire,Water&stage=Basic. - Solo keys registradas para ese TCG se aplican (las demás se ignoran). El backend mapea cada key a su path en
attributes.raw.*y arma el$match($inpara arrays/escalares; los multi-valor consplitOnusan regex). - Respuesta
facets.attributes: junto asetsyrarities,/cardsdevuelvefacets.attributes: [{ key, label, order, values: [{ value, count }] }]. Sigue Modelo B (auto-exclusión): el conteo de cada filtro ignora su propia selección pero respeta los otros, para poder sumar valores sin que el conteo caiga a 0. - Consistencia con búsqueda: cuando hay
q, las facetas se calculan vía Atlas Search ($searchcomo stage líder, mismo índicecard-searchque los resultados), con fallback a regex solo si Atlas no está disponible (tests/CI). Así el conteo de una faceta coincide con lo que devuelve al hacer click. qmatchea nombre O número (jun 2026): además del nombre,qbusca por número de carta (ej.97,97/101,OP09-095) vía el camponumberSearch(ver DB). Una query con dígitos se rutea al path indexado (regex de nombre + prefijo de número), NO a Atlas; y el conteo por TCG (/cards/counts, tabs) usa ese mismo$or(countByTcgNameOrNumber) → el número del tab coincide exacto con los resultados (sin double-count). Aplica también asearchSellerActiveListings(mis publicaciones + perfiles públicos) y al feed/listings/cards. El escáner NO usa este path (identifica por número aparte). Backfill:scripts/backfill-number-search.ts.inStockes dinámico: coninStock=true, TODAS las facetas (sets, rarezas, atributos) cuentan solo cartas con listings activos. No es una faceta sino un filtro binario, así que no se auto-excluye.
Las facetas se memoizan en proceso (TTL corto, sin Redis; inStock es parte de la clave) y se apoyan en ~19 índices parciales {tcg:1, <path>:1} sobre cards (ver base de datos).
Conteo por TCG (búsqueda global)
Sección titulada «Conteo por TCG (búsqueda global)»GET /api/v1/cards/counts?q=<término> devuelve, en una sola consulta de agregación, cuántas cartas hacen match por cada TCG: { data: [{ tcg, count }] } (solo TCGs con al menos un match). Lo usa la búsqueda global del web (cuando no hay tcg en la URL) para elegir directo el TCG con más resultados y para mostrar el conteo junto a cada tab — antes lanzaba 9 búsquedas completas en paralelo.
- Usa Atlas Search (
$searchsobre el índicecard-search, mismas cláusulas de autocomplete que/cards) +$groupportcg, con fallback a$regexsobrebaseNamesi Atlas no está disponible. qvacío →[]. Cualquier error degrada a regex y, en último caso, a[]: nunca rompe la página.- Es solo lectura y no afecta la query de resultados de
/cards(stock, orden, facetas).
Rendimiento del stock (2026-06-10): el stock de una carta NO está denormalizado; vive en listings. La búsqueda resuelve los cardIds con listings activos en UNA consulta a listings (lista chica) y la usa para el orden “con stock primero” ($in en memoria), el filtro inStock y las facetas; el $lookup de minPrice/listingsCount corre solo para la página visible (≤ 24 cartas), no para todo el catálogo. (Antes era un $lookup por carta matcheada: navegar Magic —111k cartas— tardaba ~14s.) Si algún día hay decenas de miles de cartas distintas publicadas a la vez, la mejora de fondo es denormalizar listingsCount en cards.
Listings (catálogo de mercado)
Sección titulada «Listings (catálogo de mercado)»| Método | Path | Auth | Descripción |
|---|---|---|---|
| GET | /api/v1/listings | none | Búsqueda general (admin-grade) |
| GET | /api/v1/listings/cards | none | Feed público de listings tipo card. Filtros: q, tcg, rarity (rareza de la carta de catálogo; excluye publicaciones custom), condition, sort, offset, limit + filtros de atributo por TCG (tipo de carta, energía, etc.; mismas keys del registro tcgFilters.ts, ej. ?tcg=pokemon&cardType=Pokemon,Supporter). Los atributos solo aplican con tcg |
| GET | /api/v1/listings/cards/facets | none | Facetas tcgs, rarities y attributes. Acepta q/tcg/condition/rarity (+ atributos) con Modelo B (cada faceta aplica todos los filtros menos el propio). attributes se calcula solo con tcg (son por-TCG; [] sin tcg) y la faceta tcgs NO aplica atributos (sigue conmutable entre juegos). Conteos = cartas únicas (los grupos del feed, no listings) con orden estable (count desc, desempate alfabético); total es global |
| GET | /api/v1/listings/bulk | none | Feed público de challa |
| GET | /api/v1/listings/bulk/facets | none | Facetas challa |
| GET | /api/v1/listings/sealed | none | Feed público de sellado |
| GET | /api/v1/listings/sealed/facets | none | Facetas sellado |
| GET | /api/v1/accessories | none | Feed de accesorios |
| GET | /api/v1/accessories/facets | none | Facetas accesorios |
| GET | /api/v1/listings/decks | none | Feed público de mazos |
| GET | /api/v1/listings/decks/facets | none | Facetas decks (counts por TCG) |
| GET | /api/v1/listings/custom | none | Custom listings (legacy) |
| GET | /api/v1/listings/:id/public | none | Listing público |
| GET | /api/v1/listings/:id | none | Listing detallado |
| POST | /api/v1/listings | user JWT + active | Crea listing |
| POST | /api/v1/listings/batch | user JWT + active | Publicación masiva (Publicador Pro): crea N publicaciones card en un request, resultado por carta (éxito parcial), idempotente por clientRef |
| PATCH | /api/v1/listings/:id | user JWT + active | Actualiza listing propio |
| DELETE | /api/v1/listings/:id | user JWT | Elimina listing propio |
| GET | /api/v1/users/me/listings | user JWT | Mis listings con filtros |
| GET | /api/v1/users/me/listings/facets | user JWT | Facetas de mis listings (incluye autoPriceEligibleCount = nº de cartas activas de TCGplayer, lo que actualiza “activar en todas”) |
| POST | /api/v1/users/me/listings/bulk | user JWT | Acción bulk: pause/activate/mark_sold/delete |
| POST | /api/v1/listings/:id/auto-price | user JWT + active | Activa/desactiva Precios dinámicos en una carta (body { enabled }; solo cartas de catálogo de TCGplayer elegibles). Rate-limited 60/min |
| POST | /api/v1/me/listings/auto-price/apply-all | user JWT + active | Activa/desactiva Precios dinámicos en todas las cartas elegibles del vendedor (body { enabled }). Rate-limited 10/min |
| POST | /api/v1/me/listings/auto-price/recalc | user JWT + active | Recalcula los precios ahora (todas las del vendedor, o solo una si se pasa listingId). Devuelve los precios aplicados + considered/noFreshPrice/belowMin. Rate-limited 10/min |
| GET | /api/v1/listings/auto-price/last-refresh | none | Timestamp del último refresco de precios de TCGplayer |
| GET | /api/v1/users/:username/listings | none | Listings públicos de un seller |
| GET | /api/v1/users/:username/listings/facets | none | Facetas del seller |
GET /users/me/listings incluye card.marketPrices (precio de referencia de TCGplayer, USD) por publicación de carta — lo usa el front para mostrarle al vendedor la referencia junto a su precio. Los endpoints de cartas (/cards, /cards/:id, /cards/suggest) ya exponían marketPrices.
Mazos (decks)
Sección titulada «Mazos (decks)»-
GET /api/v1/listings/decks— feed público de mazos. Query params:q(string, opc): búsqueda case-insensitive endeckTitle.tcg(string slug, opc): filtra por TCG.sort(recent|price-asc|price-desc, defaultrecent).offset(int, default 0),limit(int, default 24, max 48).- Respuesta:
{ data, total, nextOffset }.
-
GET /api/v1/listings/decks/facets— counts por TCG sobre el universo de decks activos con stock. Respuesta:{ total, tcgs: [{ slug, count }] }. -
POST /api/v1/listingsconkind: 'deck'— crear un mazo. Body:deckTitle(5-80 chars),tcg(slug),deckCardCount(1-500),photos(1-5 URL Cloudinary),description(10-500 chars),price,quantity,location.
-
PATCH /api/v1/listings/:id— admite camposdeckTitleydeckCardCountademás de los comunes. -
POST /api/v1/listingsconkind: 'card'— aceptaphotos(0-2 URLs Cloudinary) opcionales: fotos propias del vendedor de su carta real (frente/dorso), además de los campos de carta. La imagen del catálogo no cambia; estas son adicionales. ElPATCHtambién las admite (cap 2).
Precios dinámicos
Sección titulada «Precios dinámicos»Feature opt-in por vendedor: una vez activado, sus cartas de catálogo de TCGplayer se repricen solas cada día. El precio se calcula así:
precio_CLP = round100(card.marketPrices.market[USD] × usdRate_del_vendedor)
usdRatees el tipo de cambio (CLP por USD) que define el vendedor en su configuración maestra.- Redondeo: half-up al múltiplo de $100 más cercano (aritmética entera).
- Mínimo de publicación: $50. Una carta cuyo precio calculado quede por debajo no se actualiza (
belowMin).
Qué cartas aplican. Solo cartas de catálogo de TCGplayer (card.source === "tcgplayer"). Nunca Mitos y Leyendas (source api.myl.cl), sellado, accesorios ni custom.
Gate de frescura (26h). Para repricar una carta, tanto el set (sets.pricesRefreshedAt) como la carta (cards.marketPrices.pricesUpdatedAt) deben tener precio fresco (≤ 26h). Si no, esa carta se salta (no_fresh_price).
Configuración maestra del vendedor. PATCH /users/me acepta autoPricing: { enabled, usdRate } (usdRate int 1..100000 o null). Al guardar enabled: false, la API apaga el flag en todas las cartas del vendedor en el mismo request (server-side), así no quedan publicaciones reprecándose por su cuenta.
Activación por carta o masiva. POST /listings/:id/auto-price (una carta) y POST /me/listings/auto-price/apply-all (todas las elegibles) prenden/apagan el flag. POST /me/listings/auto-price/recalc recalcula los precios al instante (sin esperar al job diario) y devuelve los precios aplicados junto con los contadores considered / noFreshPrice / belowMin. La faceta autoPriceEligibleCount (en /users/me/listings/facets) indica cuántas cartas activas de TCGplayer hay (lo que “activar en todas” alcanzaría).
Seguridad de la escritura. El reprecio es una escritura quirúrgica con guard CAS { status: active, autoPrice: true } y no toca el flujo de pago: las órdenes congelan el precio al crearse, así que un reprecio nunca altera una compra en curso. (Internamente el código se llama autoPrice / autoPricing; la marca visible es “Precios dinámicos”.)
Carrito
Sección titulada «Carrito»| Método | Path | Auth | Descripción |
|---|---|---|---|
| GET | /api/v1/me/cart | user JWT | Mi carrito enriquecido |
| POST | /api/v1/me/cart/items | user JWT + active | Agrega item |
| PATCH | /api/v1/me/cart/items/:listingId | user JWT + active | Actualiza cantidad (0 = elimina) |
| DELETE | /api/v1/me/cart/items/:listingId | user JWT + active | Elimina item |
| DELETE | /api/v1/me/cart | user JWT + active | Vacía carrito |
| POST | /api/v1/me/cart/checkout | user JWT + active | Checkout del carrito (multi-vendedor) |
| POST | /api/v1/me/checkout/confirm-redirect | user JWT | Confirma un pago desde el redirect de MP (página de confirmación). Body { paymentId } |
El carrito acepta items de varios vendedores. POST /me/cart/checkout lee el carrito autoritativo del servidor, agrupa por vendedor y bifurca:
- 1 vendedor →
OrderService.createOrderWithPayment(flujo individual de siempre, reembolso a tarjeta vía MP) → responde{ kind: 'order', orderId, mpInitPoint }. - 2+ vendedores →
CheckoutService.createCartCheckout: crea una orden por vendedor + un agregadocheckout, y un solo pago combinado en MP (top-up al wallet del comprador del que se fondea cada orden) → responde{ kind: 'checkout', checkoutId, mpInitPoint }.
Mínimo de $1.500 por vendedor (no del total): si la parte de algún vendedor no lo alcanza, el checkout se rechaza nombrando al vendedor. El reembolso de una sub-orden grupal siempre va al wallet (ver decisiones → Pagos).
Confirmación post-pago (un solo camino para individual y combinado): todos los pagos de MP redirigen (back_urls) a la página /me/compras/confirmacion. Esa página toma el payment_id que MP agrega a la URL y llama a POST /me/checkout/confirm-redirect, que: verifica el pago contra MP (getPayment — no confía en los params de la URL, que son falsificables), valida que la orden/checkout sea del usuario (403 si no), confirma de forma idempotente reusando PaymentService/CheckoutService (mismo CAS que el webhook → si llegan ambos, el segundo es no-op) y devuelve el resumen. Así la confirmación no depende de esperar el webhook; el webhook queda como respaldo para cuando el comprador cierra la pestaña antes del redirect.
Órdenes
Sección titulada «Órdenes»| Método | Path | Auth | Descripción |
|---|---|---|---|
| POST | /api/v1/orders/checkout | user JWT + active | Crea orden + MP preference |
| GET | /api/v1/orders/:id | user JWT | Detalle (buyer o seller) |
| POST | /api/v1/orders/:id/resume-payment | user JWT + active | Reanuda pago si quedó en awaiting_payment |
| PATCH | /api/v1/orders/:id/accept | user JWT (seller) | Acepta |
| PATCH | /api/v1/orders/:id/ship | user JWT (seller) | Marca como enviado |
| PATCH | /api/v1/orders/:id/complete | user JWT (buyer) | Confirma recepción |
| PATCH | /api/v1/orders/:id/cancel | user JWT | Cancela. Comprador solo en pending; en accepted solo el vendedor (reembolsa al comprador). Bloqueado en shipped/completed y si hay disputa abierta. |
| GET | /api/v1/orders/:id/whatsapp | user JWT | Devuelve WhatsApp de la contraparte si activó esa preferencia |
| GET | /api/v1/me/orders/buyer | user JWT | Mis compras (cada orden incluye counterpart = vendedor: {id, username, name, avatarUrl}) |
| GET | /api/v1/me/orders/seller | user JWT | Mis ventas (cada orden incluye counterpart = comprador: {id, username, name, avatarUrl}) |
| Método | Path | Auth | Descripción |
|---|---|---|---|
| GET | /api/v1/me/unread-count | user JWT | # de chats sin leer |
| GET | /api/v1/me/conversations | user JWT | Mis conversaciones recientes |
| GET | /api/v1/conversations/:orderId/messages | user JWT | Mensajes de la orden |
| POST | /api/v1/conversations/:orderId/messages | user JWT | Envía mensaje (rate-limited) |
Reviews
Sección titulada «Reviews»| Método | Path | Auth | Descripción |
|---|---|---|---|
| POST | /api/v1/orders/:orderId/review | user JWT | Deja review (después de completar) |
| GET | /api/v1/orders/:orderId/review | user JWT | Mi review en esa orden |
| GET | /api/v1/users/:username/reviews | none | Reviews recibidas por un seller |
Disputes
Sección titulada «Disputes»| Método | Path | Auth | Descripción |
|---|---|---|---|
| POST | /api/v1/orders/:id/dispute | user JWT | Abre dispute (buyer o seller) |
| GET | /api/v1/orders/:id/dispute | user JWT | Detalle dispute |
| POST | /api/v1/orders/:id/dispute/response | user JWT | Responde dispute (la otra parte) |
| GET | /api/v1/admin/disputes | admin | Cola de disputes |
| GET | /api/v1/admin/disputes/:id | admin | Detalle admin |
| POST | /api/v1/admin/disputes/:id/resolve | admin | Resuelve (outcome + adminNote) |
Reports (moderación)
Sección titulada «Reports (moderación)»| Método | Path | Auth | Descripción |
|---|---|---|---|
| POST | /api/v1/reports | user JWT | Reporta listing o user |
| GET | /api/v1/admin/reports | admin | Lista agrupada |
| GET | /api/v1/admin/reports/:id | admin | Detalle |
Uploads
Sección titulada «Uploads»| Método | Path | Auth | Descripción |
|---|---|---|---|
| POST | /api/v1/uploads/sign | user JWT | Firma URL para subir a Cloudinary. kind: listing / proof / avatar / dispute / banner / dispute_response |
Scanner (identificación por OCR)
Sección titulada «Scanner (identificación por OCR)»El escáner identifica una carta a partir de una foto. El cliente captura el frame de la cámara, lo recorta al guía visual, lo comprime a JPEG (largo máx 1400px, calidad 0.75) y lo manda como data URL base64. El backend lo pasa por OCR.space (Engine 3), extrae el identificador (número de coleccionista) con una regex por TCG y resuelve contra el catálogo (cards). Ver el flujo completo y la migración desde pHash/Tesseract en decisiones técnicas.
| Método | Path | Auth | Descripción |
|---|---|---|---|
| POST | /api/v1/scan/identify-image | none (rate-limited) | Identifica carta desde imagen. Body { tcg, imageBase64 } |
| GET | /api/v1/admin/scanner-usage | admin | Consumo del cupo de OCR.space (hoy, mes, últimos 30 días, status) |
POST /api/v1/scan/identify-image
Sección titulada «POST /api/v1/scan/identify-image»- Tiene body parser propio de 2 MB (
express.json({ limit: '2mb' })montado antes del global de 100 kb) y un rate limiter propio (scanMatchLimiter). - Body (validado con Zod):
tcg— slug del TCG (1-64 chars).imageBase64— data URLdata:image/(jpeg|jpg|png|webp);base64,..., máx 1.5 M chars (~1 MB de imagen; tope del free tier de OCR.space). Sobre ese tamaño responde400sin gastar cuota.
- Respuesta
200:{ matches: ScanMatchEntry[] }(hasta 5).ScanMatchEntry:{ card, distance, confidence }conconfidence: 'high' | 'medium'.card:{ id, tcg, baseName, setName, setSlug, number, rarity, imageUrl }.matches: [](vacío) → “no encontrada”.
- Errores:
400 INVALID_BODY—tcg/imageBase64inválidos.503 SCANNER_QUOTA_EXCEEDED— se agotó el cupo de OCR.space (free tier). El cliente muestra “no disponible, intenta más tarde”.502 OCR_UPSTREAM_ERROR— error upstream de OCR.space.
- Tracking de cuota: cada request incrementa el contador del día en la colección
scanner_usageantes de llamar a OCR (refleja “intentos consumidos” aunque el upstream falle).
GET /api/v1/admin/scanner-usage
Sección titulada «GET /api/v1/admin/scanner-usage»Respuesta: { data: { today, thisMonth, last30Days: [{ date, count }], dailyLimit: 500, monthlyLimit: 25000, status } }, con status: 'ok' | 'warning' (≥80% diario) | 'critical' (≥100% diario).
Banners (homepage)
Sección titulada «Banners (homepage)»| Método | Path | Auth | Descripción |
|---|---|---|---|
| GET | /api/v1/banners | none | Banners activos |
| GET | /api/v1/admin/banners | admin | Todos (admin) |
| GET | /api/v1/admin/banners/:id | admin | Detalle |
| POST | /api/v1/admin/banners | admin | Crea |
| PATCH | /api/v1/admin/banners/:id | admin | Actualiza |
| POST | /api/v1/admin/banners/reorder | admin | Reordena |
| POST | /api/v1/admin/banners/:id/publish | admin | Publica |
| POST | /api/v1/admin/banners/:id/unpublish | admin | Despublica |
| POST | /api/v1/admin/banners/:id/archive | admin | Archiva |
| POST | /api/v1/admin/banners/:id/restore | admin | Restaura |
| Método | Path | Auth | Descripción |
|---|---|---|---|
| GET | /api/v1/me/wallet | user JWT | Mi balance |
| GET | /api/v1/me/wallet/entries | user JWT | Mi ledger |
| GET | /api/v1/admin/wallet-system | admin | Balance + últimas 50 del system wallet |
| POST | /api/v1/admin/wallet-system/owner-draw | admin | Owner extrae fondos del system wallet |
| GET | /api/v1/admin/users/:userId/wallet | admin | Wallet de un user (auditado) |
| POST | /api/v1/admin/users/:userId/wallet/credit | admin | Acredita |
| POST | /api/v1/admin/users/:userId/wallet/debit | admin | Debita |
| POST | /api/v1/admin/reconcile | admin | Reconciliación manual |
| GET | /api/v1/admin/finance/overview | admin | Sobres financieros (ganancia / IVA / usuarios / comisión MP / en tránsito), derivados |
| GET | /api/v1/admin/finance/monthly | admin | Desglose por mes (ventas / comisión / IVA / fee MP / ganancia neta) |
| GET | /api/v1/admin/finance/movements | admin | Movimientos de las cuentas de sistema (tcgcards + IVA) |
| POST | /api/v1/admin/finance/iva-payment | admin | Registra un pago de IVA al SII (debita la cuenta de sistema de IVA) |
Los endpoints de finanzas son read-only/derivados (calculan los sobres leyendo órdenes/payments/ledger; no tocan el flujo de pagos). La única escritura es iva-payment, sobre una cuenta de sistema separada. Ver decisión “Panel de finanzas” en decisiones.
Cuentas bancarias
Sección titulada «Cuentas bancarias»| Método | Path | Auth | Descripción |
|---|---|---|---|
| GET | /api/v1/me/bank-account | user JWT | Mi cuenta (encrypted) |
| PUT | /api/v1/me/bank-account | user JWT | Crea/actualiza |
| DELETE | /api/v1/me/bank-account | user JWT | Elimina |
| GET | /api/v1/admin/users/:userId/bank-account | admin | Decrypted (auditado) |
Withdrawals
Sección titulada «Withdrawals»| Método | Path | Auth | Descripción |
|---|---|---|---|
| POST | /api/v1/me/withdrawals | user JWT | Solicita retiro |
| GET | /api/v1/me/withdrawals | user JWT | Mis retiros |
| GET | /api/v1/admin/withdrawals | admin | Lista cola |
| GET | /api/v1/admin/withdrawals/:id | admin | Detalle |
| POST | /api/v1/admin/withdrawals/:id/complete | admin | Marca como pagado |
| POST | /api/v1/admin/withdrawals/:id/cancel | admin | Cancela |
Webhook MP
Sección titulada «Webhook MP»| Método | Path | Auth | Descripción |
|---|---|---|---|
| POST | /api/v1/webhooks/mp | MP signature | Webhook de Mercado Pago. Maneja approvals/rejects/late refunds |
Admin — moderation
Sección titulada «Admin — moderation»| Método | Path | Auth | Descripción |
|---|---|---|---|
| POST | /api/v1/admin/moderation/hide-listing | admin | Oculta listing |
| POST | /api/v1/admin/moderation/unhide-listing | admin | Restaura listing |
| POST | /api/v1/admin/moderation/warn-user | admin | Advierte user |
| POST | /api/v1/admin/moderation/suspend-user | admin | Suspende (7/30/90/null días) |
| POST | /api/v1/admin/moderation/unsuspend-user | admin | Levanta suspensión |
| POST | /api/v1/admin/moderation/ban-user | admin | Banea permanente |
| POST | /api/v1/admin/moderation/unban-user | admin | Desbanea |
| POST | /api/v1/admin/moderation/dismiss-report | admin | Desestima 1 report |
| POST | /api/v1/admin/moderation/dismiss-target-reports | admin | Desestima todos los reports de un target |
Admin — overview + orders + users
Sección titulada «Admin — overview + orders + users»| Método | Path | Auth | Descripción |
|---|---|---|---|
| GET | /api/v1/admin/overview | admin | Dashboard overview |
| GET | /api/v1/admin/orders | admin | Búsqueda de órdenes |
| GET | /api/v1/admin/orders/:id | admin | Detalle |
| GET | /api/v1/admin/users | admin | Cola moderation |
| GET | /api/v1/admin/users/:username/moderation | admin | Info moderation |
Admin — catálogo (sync)
Sección titulada «Admin — catálogo (sync)»Estos endpoints son para sincronización del catálogo desde TCGplayer. Usan X-Admin-Token.
| Método | Path | Auth | Descripción |
|---|---|---|---|
| GET | /api/v1/admin/stats | admin token | Stats por TCG |
| POST | /api/v1/admin/sync/:tcg | admin token | Dispara sync (fire-and-forget) |
Usuarios
Sección titulada «Usuarios»| Método | Path | Auth | Descripción |
|---|---|---|---|
| POST | /api/v1/users/upsert | internal auth | Crea/actualiza user desde Next.js auth bridge |
| GET | /api/v1/users/me | user JWT | Mi perfil |
| PATCH | /api/v1/users/me | user JWT | Edita perfil |
| GET | /api/v1/users/by-id/:id | none | Perfil público por id |
| GET | /api/v1/users/:username | none | Perfil público por username |
| POST | /api/v1/users/me/acknowledge-warning | user JWT | Reconoce advertencia |
| GET | /api/v1/users/me/can-delete | user JWT | Verifica si puede eliminar cuenta |
| DELETE | /api/v1/users/me | user JWT | Elimina cuenta (soft delete) |
PATCH /users/me acepta rut (RUT chileno): se valida (módulo 11, acepta K) y se guarda normalizado ("12345678-5"). Es privado — no aparece en el perfil público (/users/:username); solo lo ve el vendedor en la “Información de entrega” de una venta con envío a domicilio (junto a nombre, teléfono y correo del comprador, todos capturados como snapshot en order.buyerAddress al comprar). El checkout de envío a domicilio se bloquea (en el front) si al comprador le faltan RUT/teléfono/dirección.
PATCH /users/me también acepta autoPricing: { enabled, usdRate } — la configuración maestra de Precios dinámicos del vendedor (usdRate = CLP por USD, int 1..100000 o null). Al guardar enabled: false, la API apaga el flag en todas las publicaciones del vendedor en el mismo request. Ver Precios dinámicos en la sección Listings.
Internal cron
Sección titulada «Internal cron»| Método | Path | Auth | Descripción |
|---|---|---|---|
| POST | /api/v1/internal/cron/orders-tick | cron secret | Tick de órdenes (auto-cancel / auto-complete) |
| POST | /api/v1/internal/cron/orders-awaiting-payment-cleanup | cron secret | Cancela órdenes con timeout |
| POST | /api/v1/internal/cron/wallet-reconcile | cron secret | Reconcilia wallet |
| POST | /api/v1/internal/cron/refunds-reconcile | cron secret | Reconcilia refunds |
Notas técnicas
Sección titulada «Notas técnicas»- Rate limits globales: 600 req/min. Endpoints con limiter propio: uploads, listings POST, chat, reviews, scan (
scanMatchLimiter), Precios dinámicos (toggle por carta 60/min; apply-all y recalc 10/min). - Amounts: siempre CLP centavos (integer). Nunca pesos. Mostrarse con
formatPriceCents(). - Paginación:
?page=N(offset-based) para queries simples.?cursor=<iso>o?offset=<iso>(cursor-based) para feeds grandes.
- Timestamps: ISO 8601 UTC.
- Phone format:
569XXXXXXXX(E.164 Chile, privado).