Skip to content

Migracion a Exports API v2

Manual para clientes B2B y consumidores internos. Documenta como migrar de las 28 rutas legacy (/export/* y /reports/export/*) a la familia REST canonica /exports. ES es autoritativo; la version en ingles es traduccion revisada por humano.

Por que migramos

La API legacy nacio como endpoints sueltos GET /export/<recurso> con query params. Al crecer el catalogo de exports (24 tipos + 4 rutas nuevas de bulk reports), la inconsistencia se hizo costosa:

  • REST por sustantivo, no por verbo. El verbo HTTP describe la accion (GET lista, POST crea). El URI nombra el recurso. La familia canonica /exports agrupa todos los exports bajo un solo recurso con sub-recursos discriminados por body (jobs async) o por path (archivos pre-existentes sync).
  • Consistencia con otros recursos. Webhooks, orders, payments y todos los recursos modernos siguen el patron /<recurso> con discriminador en body. Exports era la excepcion.
  • Discoverability y codegen. Los generadores OpenAPI (typescript, java, csharp) producen SDKs mas limpios cuando los endpoints comparten un recurso comun.

Timeline

FechaHitoQue pasa
Hoy (T-60)Deploy con aliases activosLas 28 rutas legacy responden con Deprecation: true + Sunset: Sat, 01 Aug 2026 00:00:00 GMT. Las 9 rutas canonicas /exports/* estan activas.
T-30 (2026-07-01)RecordatorioEl PM/lead tecnico envia recordatorio por email + Intercom/Slack a integradores B2B. Manual de migracion en docs publicos.
T-7 (2026-07-24)Recordatorio finalRecordatorio final con oferta de soporte 1:1. Tripwire interno: si tenant_class=L o M tuvo >100 hits/sem, el sunset se puede extender 30-60 dias.
Sunset (2026-08-01)Aliases eliminadosLas 28 rutas legacy retornan 404 Not Found. El feature flag EXPORTS_V2_ALIASES_ENABLED se elimina junto con el codigo de adaptadores.

Nota: las 4 rutas /reports/export/* tienen un Sunset de 2026-07-01 (mas temprano) porque no hay clientes en produccion consumiendolas.

Tabla de mapping

Rutas legacy /export/* (24 entries)

#Ruta legacyRuta canonicaNotas
1GET /export/receivablesPOST /exports body {type:"receivables"}GET -> POST. Query params (after, before) van al body.
2GET /export/installmentsPOST /exports body {type:"installments"}GET -> POST.
3GET /export/datacreditoPOST /exports body {type:"datacredito"}GET -> POST.
4GET /export/transunionPOST /exports body {type:"transunion"}GET -> POST.
5GET /export/paymentsPOST /exports body {type:"payments"}GET -> POST. Filtros complejos en filters del body.
6GET /export/safixPOST /exports body {type:"safix"}GET -> POST.
7GET /export/safix-individualPOST /exports body {type:"safix-individual"}GET -> POST.
8GET /export/safix-clientsPOST /exports body {type:"safix-clients"}GET -> POST.
9GET /export/disbursementsPOST /exports body {type:"disbursements"}GET -> POST.
10GET /export/quantitative-scoringPOST /exports body {type:"quantitative-scoring"}GET -> POST.
11GET /export/refinancePOST /exports body {type:"refinance"}GET -> POST.
12GET /export/procreditcsvPOST /exports body {type:"procreditcsv"}GET -> POST. Incluye typeFile en body.
13GET /export/receivables/cutoffPOST /exports body {type:"receivables-cutoff"}GET -> POST.
14GET /export/installments/cutoffPOST /exports body {type:"installments-cutoff"}GET -> POST.
15GET /export/ordersPOST /exports body {type:"orders-siigo"}GET -> POST. Nombre canonico orders-siigo (era orders).
16GET /export/debtorsPOST /exports body {type:"debtors"}GET -> POST.
17GET /export/debtors/credit-activePOST /exports body {type:"debtors-credit-active"}GET -> POST.
18GET /export/debtors/credit-active/facturatechPOST /exports body {type:"debtors-credit-active-facturatech"}GET -> POST.
19GET /export/logsGET /exports/blobs/logsGET -> GET. Sync stream, path-discriminado.
20GET /export/dailyGET /exports/blobs/dailyGET -> GET. Sync stream.
21GET /export/monthlyGET /exports/blobs/monthlyGET -> GET. Sync stream.
22GET /export/orders/receivablesGET /exportsGET -> GET. Lista paginada de jobs.
23GET /export/orders/receivables/sseGET /exports/sseGET -> GET. Mismo formato SSE.
24GET /export/orders/file/:fileidGET /exports/{id}/file{id} es order_id (no file_id). El alias legacy resuelve file_id -> order_id internamente.

Rutas legacy /reports/export/* (4 entries)

#Ruta legacyRuta canonicaNotas
25POST /reports/exportPOST /exports body {type:"reports"}Mismo metodo, body discriminado.
26POST /reports/export/estimatePOST /exports?dryRun=true body {type:"reports"}Dry-run via query param. Solo type=reports soportado.
27GET /reports/export/available-sourcesGET /exports/sources?type=reportsDiscriminador como query param.
28GET /reports/export/download/:fileidGET /exports/{id}/file{id} es order_id. El alias resuelve file_id -> order_id.

Total: 28 entries (24 legacy + 4 nuevas-pero-deprecadas).

Code samples lado-a-lado

curl

Receivables (Categoria A)

bash
# Antes (legacy GET)
curl -H "Authorization: Bearer $TOKEN" \
     -H "Organization-ID: $ORG_ID" \
     "https://api.kuenta.co/v1/export/receivables?after=2026-01-01&before=2026-04-30"

# Despues (canonico POST)
curl -X POST "https://api.kuenta.co/v1/exports" \
     -H "Authorization: Bearer $TOKEN" \
     -H "Organization-ID: $ORG_ID" \
     -H "Content-Type: application/json" \
     -d '{
       "type": "receivables",
       "after": "2026-01-01T00:00:00Z",
       "before": "2026-04-30T23:59:59Z"
     }'

Datacredito (Categoria A)

bash
# Antes
curl -H "Authorization: Bearer $TOKEN" \
     -H "Organization-ID: $ORG_ID" \
     "https://api.kuenta.co/v1/export/datacredito?after=2026-01-01&before=2026-04-30"

# Despues
curl -X POST "https://api.kuenta.co/v1/exports" \
     -H "Authorization: Bearer $TOKEN" \
     -H "Organization-ID: $ORG_ID" \
     -H "Content-Type: application/json" \
     -d '{
       "type": "datacredito",
       "after": "2026-01-01T00:00:00Z",
       "before": "2026-04-30T23:59:59Z"
     }'

Reports bulk (Categoria B)

bash
# Antes (POST a /reports/export)
curl -X POST "https://api.kuenta.co/v1/reports/export" \
     -H "Authorization: Bearer $TOKEN" \
     -H "Organization-ID: $ORG_ID" \
     -H "Content-Type: application/json" \
     -d '{
       "after": "2026-01-01T00:00:00Z",
       "before": "2026-04-30T23:59:59Z",
       "format": "consolidated",
       "columns": ["date", "name", "status"]
     }'

# Despues (POST a /exports con type discriminado)
curl -X POST "https://api.kuenta.co/v1/exports" \
     -H "Authorization: Bearer $TOKEN" \
     -H "Organization-ID: $ORG_ID" \
     -H "Content-Type: application/json" \
     -d '{
       "type": "reports",
       "after": "2026-01-01T00:00:00Z",
       "before": "2026-04-30T23:59:59Z",
       "format": "consolidated",
       "columns": ["date", "name", "status"]
     }'

Sources disponibles

bash
# Antes
curl -H "Authorization: Bearer $TOKEN" \
     -H "Organization-ID: $ORG_ID" \
     "https://api.kuenta.co/v1/reports/export/available-sources"

# Despues
curl -H "Authorization: Bearer $TOKEN" \
     -H "Organization-ID: $ORG_ID" \
     "https://api.kuenta.co/v1/exports/sources?type=reports"

fetch (TypeScript)

Crear export

typescript
// Antes (legacy GET con stream sync)
async function exportReceivablesLegacy(after: string, before: string) {
  const params = new URLSearchParams({ after, before });
  const res = await fetch(`/v1/export/receivables?${params}`, {
    headers: {
      Authorization: `Bearer ${token}`,
      'Organization-ID': orgId,
    },
  });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return await res.blob(); // CSV inline
}

// Despues (canonico async, devuelve order_id)
async function exportReceivables(after: string, before: string) {
  const res = await fetch('/v1/exports', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${token}`,
      'Organization-ID': orgId,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ type: 'receivables', after, before }),
  });
  if (res.status !== 202) throw new Error(`HTTP ${res.status}`);
  const { data } = await res.json();
  return data.orderID; // string
}

Polling de estado

typescript
// Antes: no aplica (era sync, el CSV venia en la respuesta)

// Despues: polling hasta done
async function waitForExport(orderID: string): Promise<void> {
  while (true) {
    const res = await fetch(`/v1/exports/${orderID}`, {
      headers: { Authorization: `Bearer ${token}`, 'Organization-ID': orgId },
    });
    const { data } = await res.json();
    if (data.status === 'done') return;
    if (data.status === 'failed') throw new Error(data.error);
    await new Promise(r => setTimeout(r, 5000));
  }
}

Estimacion (dry-run)

typescript
// Antes (POST a /reports/export/estimate)
async function estimateReportsLegacy(filter: object) {
  const res = await fetch('/v1/reports/export/estimate', {
    method: 'POST',
    headers: { Authorization: `Bearer ${token}`, 'Organization-ID': orgId, 'Content-Type': 'application/json' },
    body: JSON.stringify(filter),
  });
  return await res.json();
}

// Despues (POST /exports?dryRun=true)
async function estimateReports(filter: object) {
  const res = await fetch('/v1/exports?dryRun=true', {
    method: 'POST',
    headers: { Authorization: `Bearer ${token}`, 'Organization-ID': orgId, 'Content-Type': 'application/json' },
    body: JSON.stringify({ type: 'reports', ...filter }),
  });
  if (res.status === 501) {
    const { data } = await res.json();
    throw new Error(`dryRun not supported for this type. Supported: ${data.supportedTypes.join(', ')}`);
  }
  return await res.json();
}

SSE

typescript
// Antes
const sourceLegacy = new EventSource('/v1/export/orders/receivables/sse');

// Despues
const source = new EventSource('/v1/exports/sse');
source.addEventListener('export.updated', (e) => {
  const data = JSON.parse(e.data);
  console.log(`Export ${data.id} -> ${data.status}`);
});

axios (Node.js)

Crear y esperar

typescript
// Antes (axios con GET stream)
import axios from 'axios';
import fs from 'fs';

async function exportLegacy() {
  const res = await axios.get('https://api.kuenta.co/v1/export/installments', {
    headers: { Authorization: `Bearer ${token}`, 'Organization-ID': orgId },
    params: { after: '2026-01-01', before: '2026-04-30' },
    responseType: 'stream',
  });
  res.data.pipe(fs.createWriteStream('installments.csv'));
}

// Despues (axios con POST async + polling)
async function exportNew() {
  const create = await axios.post(
    'https://api.kuenta.co/v1/exports',
    { type: 'installments', after: '2026-01-01T00:00:00Z', before: '2026-04-30T23:59:59Z' },
    { headers: { Authorization: `Bearer ${token}`, 'Organization-ID': orgId } }
  );
  const orderID = create.data.data.orderID;

  while (true) {
    const status = await axios.get(`https://api.kuenta.co/v1/exports/${orderID}`, {
      headers: { Authorization: `Bearer ${token}`, 'Organization-ID': orgId },
    });
    if (status.data.data.status === 'done') break;
    if (status.data.data.status === 'failed') throw new Error('export failed');
    await new Promise(r => setTimeout(r, 5000));
  }

  const file = await axios.get(`https://api.kuenta.co/v1/exports/${orderID}/file`, {
    headers: { Authorization: `Bearer ${token}`, 'Organization-ID': orgId },
    responseType: 'stream',
  });
  file.data.pipe(fs.createWriteStream('installments.csv'));
}

Detectar deprecation header

typescript
// Detecta y loguea Deprecation/Sunset en cualquier respuesta
axios.interceptors.response.use((res) => {
  if (res.headers.deprecation === 'true') {
    console.warn(
      `[DEPRECATED] ${res.config.url} retira el ${res.headers.sunset}. ` +
      `Migrar: ${res.headers.link}`
    );
  }
  return res;
});

Descarga con manejo de 410

typescript
async function download(orderID: string, outFile: string) {
  try {
    const res = await axios.get(
      `https://api.kuenta.co/v1/exports/${orderID}/file`,
      {
        headers: { Authorization: `Bearer ${token}`, 'Organization-ID': orgId },
        responseType: 'stream',
      }
    );
    res.data.pipe(fs.createWriteStream(outFile));
  } catch (e: any) {
    if (e.response?.status === 410) {
      const { data } = e.response.data;
      throw new Error(`Archivo expirado el ${data.expiredAt}. Re-crea el export.`);
    }
    throw e;
  }
}

Descarga de blob sync (logs)

typescript
// Antes
await axios.get('https://api.kuenta.co/v1/export/logs', {
  headers: { Authorization: `Bearer ${token}`, 'Organization-ID': orgId },
  responseType: 'stream',
});

// Despues
await axios.get('https://api.kuenta.co/v1/exports/blobs/logs', {
  headers: { Authorization: `Bearer ${token}`, 'Organization-ID': orgId },
  responseType: 'stream',
});

Pitfalls

1. GET -> POST: los query params van al body

La mayoria de rutas legacy eran GET ?after=...&before=.... La canonica es POST con Content-Type: application/json y los filtros en el body. Si pegas el URL viejo a un cliente POST sin cambiar el shape vas a tener 400 BIND_ERROR.

diff
- GET /v1/export/receivables?after=2026-01-01&before=2026-04-30
+ POST /v1/exports
+ Content-Type: application/json
+ {"type":"receivables","after":"2026-01-01T00:00:00Z","before":"2026-04-30T23:59:59Z"}

2. dryRun=true solo para type=reports

POST /exports?dryRun=true solo esta implementado para type=reports. Cualquier otro tipo retorna 501 Not Implemented:

json
{
  "status": "fail",
  "data": {
    "code": "DRY_RUN_NOT_SUPPORTED",
    "supportedTypes": ["reports"]
  }
}

El roadmap es ampliar la lista de supportedTypes sprint a sprint. Consulta esta pagina o el campo supportedTypes del 501.

3. {id} en download = order_id, no file_id

La ruta canonica GET /exports/{id}/file usa order_id. La ruta legacy GET /export/orders/file/:fileid usaba file_id. El adaptador legacy resuelve file_id -> order_id internamente, pero si migras al canonico debes guardar y enviar el order_id retornado por POST /exports (campo data.orderID).

ts
// Mal: usar el viejo file_id
fetch(`/v1/exports/${fileId}/file`); // 404

// Bien: usar el order_id del POST
const { data } = await (await fetch('/v1/exports', { method: 'POST', body: '...' })).json();
fetch(`/v1/exports/${data.orderID}/file`);

4. Headers Sunset deben respetarse

Las rutas legacy retornan Deprecation: true + Sunset: Sat, 01 Aug 2026 00:00:00 GMT + Link: <successor-version>. Configura tu cliente para loguear o alertar cuando aparezcan, asi te enteras a tiempo y no te pilla el sunset.

ts
if (response.headers.get('deprecation') === 'true') {
  log.warn(`Endpoint deprecated. Sunset: ${response.headers.get('sunset')}`);
}

5. Blobs sync siguen siendo GET

/export/logs, /export/daily, /export/monthly migran a GET /exports/blobs/{type}. No cambian a POST porque representan archivos pre-existentes (sub-recurso sync), no jobs. Si tu cliente asume "todo es POST" en la nueva API se va a romper.

diff
- GET /v1/export/logs
+ GET /v1/exports/blobs/logs

6. Cambio de sync a async para algunos endpoints

Los exports legacy GET /export/<recurso> retornaban el CSV inline con 200 OK. La ruta canonica POST /exports encola un job y retorna 202 Accepted + Location. Tu cliente debe:

  1. Hacer el POST y guardar el orderID.
  2. Pollear GET /exports/{orderID} hasta status="done" o suscribirse a GET /exports/sse.
  3. Descargar con GET /exports/{orderID}/file.

Para clientes que no pueden manejar async, los aliases legacy siguen funcionando hasta el sunset.

FAQ

Q1: Tengo que migrar inmediatamente?

No. Los aliases legacy responden hasta el Sunset: 2026-08-01 (60 dias desde el deploy). Tienes ese rango para migrar sin urgencia. Recomendamos hacerlo en T-30 (julio 2026) para tener buffer ante imprevistos.

Q2: Que pasa si no migro antes del sunset?

A partir del sunset, las 28 rutas legacy retornan 404 Not Found. Tu integracion va a romper. Si tienes traffic significativo cerca del sunset, contactanos: nuestro tripwire interno puede extender el sunset 30-60 dias si detecta uso real de tenants L o M.

Q3: Las rutas viejas seguiran funcionando byte-a-byte?

Equivalencia funcional, no byte-a-byte. Los handlers son distintos (adaptadores legacy vs handlers canonicos), pero garantizamos: mismo dataset, mismas columnas, mismo orden, mismo encoding UTF-8 sin BOM, mismo Content-Type y Content-Disposition. Lo que no garantizamos es orden exacto de headers HTTP no semanticos o tiempos de respuesta byte-por-byte. Si tu integracion depende de detalles wire-level no documentados, abre un ticket.

Q4: Como manejo el cambio sync -> async para algunos endpoints?

Tres opciones:

  1. Polling: hacer POST /exports, guardar orderID, y pollear GET /exports/{orderID} cada 5s hasta done.
  2. SSE: suscribirse a GET /exports/sse y reaccionar al evento export.updated con el orderID correcto.
  3. Quedarse con el alias legacy hasta el sunset: si tu sistema no puede manejar async, sigue usando GET /export/<recurso> hasta 2026-08-01. Despues debes migrar si o si.

Q5: Mi sistema generado con OpenAPI codegen rompe?

Posiblemente. Las 28 rutas legacy estan marcadas con "deprecated": true + x-sunset: 2026-08-01 en openapi.json. Esto puede:

  • Imprimir warnings durante la generacion (la mayoria de generadores).
  • Fallar el build si tienes --fail-on-warnings o equivalente.

Ver la seccion Si usas OpenAPI codegen para flags por generador.

Q6: Puedo seguir usando GET en lugar de POST?

No para los exports async. La canonica es POST /exports con body. Si tu cliente solo hace GET, mantente con los aliases legacy hasta el sunset y luego migra. Para los blobs sync (logs, daily, monthly), GET /exports/blobs/{type} sigue siendo GET.

Q7: Como monitoreo si mi codigo sigue usando rutas viejas?

Inspecciona los headers de respuesta. Cualquier hit a las rutas legacy retorna Deprecation: true. Configura un interceptor en tu HTTP client:

ts
if (response.headers.get('deprecation') === 'true') {
  metrics.increment('api.deprecated', { path: response.url });
}

Internamente Kuenta tambien tiene un counter Prometheus export_alias_hits_total{old_path, new_path, tenant_class} y trace exemplars con tenant_id exacto. Tu CSM puede consultarlo.

Q8: Que permisos necesito?

EndpointPermiso
POST /exports con type=receivables/installments/etc.ReadExport + permiso por dominio (ReadReceivables, ReadOrder, ReadChildEntity)
POST /exports con type=reportsExportReports
POST /exports?dryRun=true con type=reportsExportReports
GET /exports/sources?type=reportsExportReports
GET /exports/blobs/{type}ReadExport + ReadReceivables
GET /exports/sseReadExport + ReadOrder

Los permisos no cambian respecto a la API legacy.

Q9: Hay rate limits nuevos?

Los rate limits del tenant siguen siendo los mismos. POST /exports con type=reports puede retornar 429 Too Many Requests si excedes el cap del tenant — el body incluye Retry-After en segundos.

Q10: Como reporto un bug en las nuevas rutas?

Email: [email protected] (sujeto a confirmacion por PM). Slack interno (clientes con canal compartido): #api-migrations. Incluye:

  • request_id de la respuesta (header X-Request-ID).
  • Tenant ID y timestamp.
  • Request body completo (sin PII real — usa fixtures si es posible).
  • Response status + headers + body.

Si usas OpenAPI codegen

Las 28 rutas legacy estan marcadas con "deprecated": true y la extension x-sunset: 2026-08-01 en docs/vitepress/src/openapi.json. Esto afecta a los generadores asi:

openapi-typescript

Genera tipos para todas las rutas, incluidas las deprecadas. Si quieres excluirlas del output:

bash
# Mantener todo (default)
npx openapi-typescript https://api.kuenta.co/v1/openapi.json -o api.d.ts

# Filtrar deprecadas (custom, requiere postprocesamiento)
npx openapi-typescript https://api.kuenta.co/v1/openapi.json -o api.d.ts
# Luego: grep -v '@deprecated' api.d.ts > api-clean.d.ts

openapi-generator-cli (java / csharp / typescript-fetch)

bash
# Mantener metodos deprecados (default)
openapi-generator-cli generate \
  -i https://api.kuenta.co/v1/openapi.json \
  -g typescript-fetch \
  -o ./client \
  --additional-properties=supportsES6=true

# Para silenciar warnings de deprecated:
openapi-generator-cli generate \
  -i https://api.kuenta.co/v1/openapi.json \
  -g typescript-fetch \
  -o ./client \
  --skip-deprecated=false

Recomendacion: usa --skip-deprecated=false (default) durante la transicion para mantener los metodos legacy en tu SDK. Despues del sunset, regenera sin la flag o con --skip-deprecated=true para limpiar.

swagger-codegen-cli

bash
# Default: incluye deprecadas con warning
swagger-codegen-cli generate \
  -i https://api.kuenta.co/v1/openapi.json \
  -l typescript-axios \
  -o ./client

Swagger-codegen no tiene flag para excluir deprecadas; debes filtrar el output o esperar al sunset.

Recomendacion general

  1. Hoy: regenera tu SDK; el codigo seguira compilando porque las legacy estan marcadas deprecated pero no removidas.
  2. T-30: revisa que tu codigo de produccion no use simbolos @deprecated. Migra los callsites al canonico.
  3. Post-sunset: regenera con la API limpia. El SDK ya no tendra los metodos legacy.

Tipos que soportan dryRun

TipodryRun soportadoComportamiento
reportsSiRetorna 200 + {itemCount, overflowed, softWarn} sin crear estado
receivables, installments, payments, disbursements, refinance, debtors, datacredito, transunion, safix*, procreditcsv, quantitative-scoring, orders-siigo, receivables-cutoff, installments-cutoff, debtors-credit-active*NoRetorna 501 Not Implemented con {code: "DRY_RUN_NOT_SUPPORTED", supportedTypes: ["reports"]}

El roadmap es ampliar progresivamente sprint a sprint segun demanda. Si tu integracion necesita dry-run para un tipo especifico, contactanos.

Contacto y soporte

  • Email: [email protected] (placeholder; PM confirma direccion definitiva)
  • Slack interno: #api-migrations (clientes con canal compartido)
  • Issues publicos: GitLab issues
  • Docs: este sitio (docs/vitepress/migration/exports-api-v2.es.md)

Proximos pasos