Appearance
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 (
GETlista,POSTcrea). El URI nombra el recurso. La familia canonica/exportsagrupa 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
| Fecha | Hito | Que pasa |
|---|---|---|
| Hoy (T-60) | Deploy con aliases activos | Las 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) | Recordatorio | El PM/lead tecnico envia recordatorio por email + Intercom/Slack a integradores B2B. Manual de migracion en docs publicos. |
| T-7 (2026-07-24) | Recordatorio final | Recordatorio 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 eliminados | Las 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 unSunsetde2026-07-01(mas temprano) porque no hay clientes en produccion consumiendolas.
Tabla de mapping
Rutas legacy /export/* (24 entries)
| # | Ruta legacy | Ruta canonica | Notas |
|---|---|---|---|
| 1 | GET /export/receivables | POST /exports body {type:"receivables"} | GET -> POST. Query params (after, before) van al body. |
| 2 | GET /export/installments | POST /exports body {type:"installments"} | GET -> POST. |
| 3 | GET /export/datacredito | POST /exports body {type:"datacredito"} | GET -> POST. |
| 4 | GET /export/transunion | POST /exports body {type:"transunion"} | GET -> POST. |
| 5 | GET /export/payments | POST /exports body {type:"payments"} | GET -> POST. Filtros complejos en filters del body. |
| 6 | GET /export/safix | POST /exports body {type:"safix"} | GET -> POST. |
| 7 | GET /export/safix-individual | POST /exports body {type:"safix-individual"} | GET -> POST. |
| 8 | GET /export/safix-clients | POST /exports body {type:"safix-clients"} | GET -> POST. |
| 9 | GET /export/disbursements | POST /exports body {type:"disbursements"} | GET -> POST. |
| 10 | GET /export/quantitative-scoring | POST /exports body {type:"quantitative-scoring"} | GET -> POST. |
| 11 | GET /export/refinance | POST /exports body {type:"refinance"} | GET -> POST. |
| 12 | GET /export/procreditcsv | POST /exports body {type:"procreditcsv"} | GET -> POST. Incluye typeFile en body. |
| 13 | GET /export/receivables/cutoff | POST /exports body {type:"receivables-cutoff"} | GET -> POST. |
| 14 | GET /export/installments/cutoff | POST /exports body {type:"installments-cutoff"} | GET -> POST. |
| 15 | GET /export/orders | POST /exports body {type:"orders-siigo"} | GET -> POST. Nombre canonico orders-siigo (era orders). |
| 16 | GET /export/debtors | POST /exports body {type:"debtors"} | GET -> POST. |
| 17 | GET /export/debtors/credit-active | POST /exports body {type:"debtors-credit-active"} | GET -> POST. |
| 18 | GET /export/debtors/credit-active/facturatech | POST /exports body {type:"debtors-credit-active-facturatech"} | GET -> POST. |
| 19 | GET /export/logs | GET /exports/blobs/logs | GET -> GET. Sync stream, path-discriminado. |
| 20 | GET /export/daily | GET /exports/blobs/daily | GET -> GET. Sync stream. |
| 21 | GET /export/monthly | GET /exports/blobs/monthly | GET -> GET. Sync stream. |
| 22 | GET /export/orders/receivables | GET /exports | GET -> GET. Lista paginada de jobs. |
| 23 | GET /export/orders/receivables/sse | GET /exports/sse | GET -> GET. Mismo formato SSE. |
| 24 | GET /export/orders/file/:fileid | GET /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 legacy | Ruta canonica | Notas |
|---|---|---|---|
| 25 | POST /reports/export | POST /exports body {type:"reports"} | Mismo metodo, body discriminado. |
| 26 | POST /reports/export/estimate | POST /exports?dryRun=true body {type:"reports"} | Dry-run via query param. Solo type=reports soportado. |
| 27 | GET /reports/export/available-sources | GET /exports/sources?type=reports | Discriminador como query param. |
| 28 | GET /reports/export/download/:fileid | GET /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/logs6. 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:
- Hacer el
POSTy guardar elorderID. - Pollear
GET /exports/{orderID}hastastatus="done"o suscribirse aGET /exports/sse. - 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:
- Polling: hacer
POST /exports, guardarorderID, y pollearGET /exports/{orderID}cada 5s hastadone. - SSE: suscribirse a
GET /exports/ssey reaccionar al eventoexport.updatedcon elorderIDcorrecto. - Quedarse con el alias legacy hasta el sunset: si tu sistema no puede manejar async, sigue usando
GET /export/<recurso>hasta2026-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-warningso 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?
| Endpoint | Permiso |
|---|---|
POST /exports con type=receivables/installments/etc. | ReadExport + permiso por dominio (ReadReceivables, ReadOrder, ReadChildEntity) |
POST /exports con type=reports | ExportReports |
POST /exports?dryRun=true con type=reports | ExportReports |
GET /exports/sources?type=reports | ExportReports |
GET /exports/blobs/{type} | ReadExport + ReadReceivables |
GET /exports/sse | ReadExport + 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_idde la respuesta (headerX-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.tsopenapi-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=falseRecomendacion: 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 ./clientSwagger-codegen no tiene flag para excluir deprecadas; debes filtrar el output o esperar al sunset.
Recomendacion general
- Hoy: regenera tu SDK; el codigo seguira compilando porque las legacy estan marcadas
deprecatedpero no removidas. - T-30: revisa que tu codigo de produccion no use simbolos
@deprecated. Migra los callsites al canonico. - Post-sunset: regenera con la API limpia. El SDK ya no tendra los metodos legacy.
Tipos que soportan dryRun
| Tipo | dryRun soportado | Comportamiento |
|---|---|---|
reports | Si | Retorna 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* | No | Retorna 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)