Appearance
Migration to Exports API v2
Manual for B2B clients and internal consumers. Documents how to migrate from the 28 legacy routes (/export/* and /reports/export/*) to the canonical REST /exports family. ES is authoritative; this English version is a human-reviewed translation.
Why we are migrating
The legacy API was born as standalone GET /export/<resource> endpoints with query params. As the export catalog grew (24 types + 4 new bulk-report routes), inconsistency became costly:
- REST by noun, not by verb. The HTTP verb describes the action (
GETlists,POSTcreates). The URI names the resource. The canonical/exportsfamily groups all exports under a single resource with sub-resources discriminated by body (async jobs) or by path (pre-existing sync files). - Consistency with other resources. Webhooks, orders, payments and all modern resources follow the
/<resource>pattern with body discriminator. Exports was the exception. - Discoverability and codegen. OpenAPI generators (typescript, java, csharp) produce cleaner SDKs when endpoints share a common resource.
Timeline
| Date | Milestone | What happens |
|---|---|---|
| Today (T-60) | Deploy with aliases active | The 28 legacy routes respond with Deprecation: true + Sunset: Sat, 01 Aug 2026 00:00:00 GMT. The 9 canonical /exports/* routes are live. |
| T-30 (2026-07-01) | Reminder | The PM / tech lead sends an email + Intercom/Slack reminder to B2B integrators. Migration manual in public docs. |
| T-7 (2026-07-24) | Final reminder | Final reminder with 1:1 support offer. Internal tripwire: if tenant_class=L or M had >100 hits/wk, the sunset can be extended 30-60 days. |
| Sunset (2026-08-01) | Aliases removed | The 28 legacy routes return 404 Not Found. The EXPORTS_V2_ALIASES_ENABLED feature flag is removed together with the adapter code. |
Note: the 4
/reports/export/*routes have aSunsetof2026-07-01(earlier) because there are no production clients consuming them.
Mapping table
Legacy /export/* routes (24 entries)
| # | Legacy route | Canonical route | Notes |
|---|---|---|---|
| 1 | GET /export/receivables | POST /exports body {type:"receivables"} | GET -> POST. Query params (after, before) move to the 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. Complex filters in body filters. |
| 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. Includes typeFile in 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. Canonical name orders-siigo (was 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-discriminated. |
| 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. Paginated job list. |
| 23 | GET /export/orders/receivables/sse | GET /exports/sse | GET -> GET. Same SSE format. |
| 24 | GET /export/orders/file/:fileid | GET /exports/{id}/file | {id} is order_id (not file_id). The legacy alias resolves file_id -> order_id internally. |
Legacy /reports/export/* routes (4 entries)
| # | Legacy route | Canonical route | Notes |
|---|---|---|---|
| 25 | POST /reports/export | POST /exports body {type:"reports"} | Same method, body-discriminated. |
| 26 | POST /reports/export/estimate | POST /exports?dryRun=true body {type:"reports"} | Dry-run via query param. Only type=reports supported. |
| 27 | GET /reports/export/available-sources | GET /exports/sources?type=reports | Discriminator as query param. |
| 28 | GET /reports/export/download/:fileid | GET /exports/{id}/file | {id} is order_id. The alias resolves file_id -> order_id. |
Total: 28 entries (24 legacy + 4 new-but-deprecated).
Side-by-side code samples
curl
Receivables (Category A)
bash
# Before (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"
# After (canonical 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 (Category A)
bash
# Before
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"
# After
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 (Category B)
bash
# Before (POST to /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"]
}'
# After (POST to /exports with discriminator type)
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"]
}'Available sources
bash
# Before
curl -H "Authorization: Bearer $TOKEN" \
-H "Organization-ID: $ORG_ID" \
"https://api.kuenta.co/v1/reports/export/available-sources"
# After
curl -H "Authorization: Bearer $TOKEN" \
-H "Organization-ID: $ORG_ID" \
"https://api.kuenta.co/v1/exports/sources?type=reports"fetch (TypeScript)
Create export
typescript
// Before (legacy GET with sync stream)
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(); // inline CSV
}
// After (async canonical, returns 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
}Status polling
typescript
// Before: not applicable (it was sync, the CSV came in the response)
// After: poll until 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));
}
}Estimate (dry-run)
typescript
// Before (POST to /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();
}
// After (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
// Before
const sourceLegacy = new EventSource('/v1/export/orders/receivables/sse');
// After
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)
Create and wait
typescript
// Before (axios with 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'));
}
// After (axios with async POST + 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'));
}Detect deprecation header
typescript
// Detect and log Deprecation/Sunset on any response
axios.interceptors.response.use((res) => {
if (res.headers.deprecation === 'true') {
console.warn(
`[DEPRECATED] ${res.config.url} retires on ${res.headers.sunset}. ` +
`Migrate to: ${res.headers.link}`
);
}
return res;
});Download with 410 handling
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(`File expired at ${data.expiredAt}. Re-create the export.`);
}
throw e;
}
}Sync blob download (logs)
typescript
// Before
await axios.get('https://api.kuenta.co/v1/export/logs', {
headers: { Authorization: `Bearer ${token}`, 'Organization-ID': orgId },
responseType: 'stream',
});
// After
await axios.get('https://api.kuenta.co/v1/exports/blobs/logs', {
headers: { Authorization: `Bearer ${token}`, 'Organization-ID': orgId },
responseType: 'stream',
});Pitfalls
1. GET -> POST: query params move to the body
Most legacy routes were GET ?after=...&before=.... The canonical is POST with Content-Type: application/json and filters in the body. If you paste the old URL into a POST client without changing the shape you will get 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 only for type=reports
POST /exports?dryRun=true is only implemented for type=reports. Any other type returns 501 Not Implemented:
json
{
"status": "fail",
"data": {
"code": "DRY_RUN_NOT_SUPPORTED",
"supportedTypes": ["reports"]
}
}The roadmap is to expand the supportedTypes list sprint by sprint. Check this page or the supportedTypes field of the 501.
3. {id} in download = order_id, not file_id
The canonical route GET /exports/{id}/file uses order_id. The legacy route GET /export/orders/file/:fileid used file_id. The legacy alias resolves file_id -> order_id internally, but if you migrate to the canonical route you must save and send the order_id returned by POST /exports (field data.orderID).
ts
// Wrong: using the old file_id
fetch(`/v1/exports/${fileId}/file`); // 404
// Right: using the order_id from the POST
const { data } = await (await fetch('/v1/exports', { method: 'POST', body: '...' })).json();
fetch(`/v1/exports/${data.orderID}/file`);4. Sunset headers must be respected
Legacy routes return Deprecation: true + Sunset: Sat, 01 Aug 2026 00:00:00 GMT + Link: <successor-version>. Configure your client to log or alert when they appear so you find out in time and the sunset does not catch you off guard.
ts
if (response.headers.get('deprecation') === 'true') {
log.warn(`Endpoint deprecated. Sunset: ${response.headers.get('sunset')}`);
}5. Sync blobs are still GET
/export/logs, /export/daily, /export/monthly migrate to GET /exports/blobs/{type}. They do not switch to POST because they represent pre-existing files (sync sub-resource), not jobs. If your client assumes "everything is POST" in the new API it will break.
diff
- GET /v1/export/logs
+ GET /v1/exports/blobs/logs6. Sync-to-async change for some endpoints
The legacy exports GET /export/<resource> returned the CSV inline with 200 OK. The canonical POST /exports enqueues a job and returns 202 Accepted + Location. Your client must:
- Make the
POSTand save theorderID. - Poll
GET /exports/{orderID}untilstatus="done"or subscribe toGET /exports/sse. - Download with
GET /exports/{orderID}/file.
For clients that cannot handle async, the legacy aliases still work until the sunset.
FAQ
Q1: Do I have to migrate immediately?
No. The legacy aliases respond until Sunset: 2026-08-01 (60 days from deploy). You have that window to migrate without urgency. We recommend doing it at T-30 (July 2026) to have a buffer for unexpected issues.
Q2: What happens if I do not migrate before the sunset?
From the sunset onward, the 28 legacy routes return 404 Not Found. Your integration will break. If you have significant traffic close to the sunset, contact us: our internal tripwire can extend the sunset 30-60 days if it detects real usage by L or M tenants.
Q3: Will the old routes keep working byte-for-byte?
Functional equivalence, not byte-for-byte. Handlers are different (legacy adapters vs canonical handlers), but we guarantee: same dataset, same columns, same order, same UTF-8 encoding without BOM, same Content-Type and Content-Disposition. What we do not guarantee is exact ordering of non-semantic HTTP headers or byte-by-byte response timings. If your integration depends on undocumented wire-level details, open a ticket.
Q4: How do I handle the sync -> async change for some endpoints?
Three options:
- Polling:
POST /exports, saveorderID, and pollGET /exports/{orderID}every 5s untildone. - SSE: subscribe to
GET /exports/sseand react to theexport.updatedevent with the correctorderID. - Stay with the legacy alias until sunset: if your system cannot handle async, keep using
GET /export/<resource>until2026-08-01. After that you must migrate.
Q5: Does my OpenAPI codegen-generated system break?
Possibly. The 28 legacy routes are marked "deprecated": true + x-sunset: 2026-08-01 in openapi.json. This can:
- Print warnings during generation (most generators).
- Fail the build if you have
--fail-on-warningsor equivalent.
See the If you use OpenAPI codegen section for per-generator flags.
Q6: Can I keep using GET instead of POST?
Not for async exports. The canonical is POST /exports with body. If your client only does GET, stay with the legacy aliases until sunset and then migrate. For sync blobs (logs, daily, monthly), GET /exports/blobs/{type} is still GET.
Q7: How do I monitor if my code still uses old routes?
Inspect response headers. Any hit on legacy routes returns Deprecation: true. Configure an interceptor in your HTTP client:
ts
if (response.headers.get('deprecation') === 'true') {
metrics.increment('api.deprecated', { path: response.url });
}Internally Kuenta also has a Prometheus counter export_alias_hits_total{old_path, new_path, tenant_class} and trace exemplars with the exact tenant_id. Your CSM can query it.
Q8: What permissions do I need?
| Endpoint | Permission |
|---|---|
POST /exports with type=receivables/installments/etc. | ReadExport + domain permission (ReadReceivables, ReadOrder, ReadChildEntity) |
POST /exports with type=reports | ExportReports |
POST /exports?dryRun=true with type=reports | ExportReports |
GET /exports/sources?type=reports | ExportReports |
GET /exports/blobs/{type} | ReadExport + ReadReceivables |
GET /exports/sse | ReadExport + ReadOrder |
Permissions are unchanged from the legacy API.
Q9: Are there new rate limits?
Tenant rate limits are unchanged. POST /exports with type=reports can return 429 Too Many Requests if you exceed the tenant cap — the body includes Retry-After in seconds.
Q10: How do I report a bug in the new routes?
Email: [email protected] (subject to PM confirmation). Internal Slack (clients with shared channel): #api-migrations. Include:
request_idfrom the response (headerX-Request-ID).- Tenant ID and timestamp.
- Full request body (without real PII — use fixtures if possible).
- Response status + headers + body.
If you use OpenAPI codegen
The 28 legacy routes are marked "deprecated": true and the vendor extension x-sunset: 2026-08-01 in docs/vitepress/src/openapi.json. This affects generators as follows:
openapi-typescript
Generates types for all routes, including deprecated ones. To exclude them from the output:
bash
# Keep everything (default)
npx openapi-typescript https://api.kuenta.co/v1/openapi.json -o api.d.ts
# Filter deprecated (custom, requires postprocessing)
npx openapi-typescript https://api.kuenta.co/v1/openapi.json -o api.d.ts
# Then: grep -v '@deprecated' api.d.ts > api-clean.d.tsopenapi-generator-cli (java / csharp / typescript-fetch)
bash
# Keep deprecated methods (default)
openapi-generator-cli generate \
-i https://api.kuenta.co/v1/openapi.json \
-g typescript-fetch \
-o ./client \
--additional-properties=supportsES6=true
# To silence deprecated warnings:
openapi-generator-cli generate \
-i https://api.kuenta.co/v1/openapi.json \
-g typescript-fetch \
-o ./client \
--skip-deprecated=falseRecommendation: use --skip-deprecated=false (default) during the transition to keep legacy methods in your SDK. After sunset, regenerate without the flag or with --skip-deprecated=true to clean up.
swagger-codegen-cli
bash
# Default: includes deprecated with a warning
swagger-codegen-cli generate \
-i https://api.kuenta.co/v1/openapi.json \
-l typescript-axios \
-o ./clientSwagger-codegen does not have a flag to exclude deprecated routes; you must filter the output or wait for the sunset.
General recommendation
- Today: regenerate your SDK; the code keeps compiling because legacy routes are marked
deprecatedbut not removed. - T-30: review that your production code does not use
@deprecatedsymbols. Migrate callsites to the canonical. - Post-sunset: regenerate with the clean API. The SDK will no longer have the legacy methods.
Types supporting dryRun
| Type | dryRun supported | Behavior |
|---|---|---|
reports | Yes | Returns 200 + {itemCount, overflowed, softWarn} without creating state |
receivables, installments, payments, disbursements, refinance, debtors, datacredito, transunion, safix*, procreditcsv, quantitative-scoring, orders-siigo, receivables-cutoff, installments-cutoff, debtors-credit-active* | No | Returns 501 Not Implemented with {code: "DRY_RUN_NOT_SUPPORTED", supportedTypes: ["reports"]} |
The roadmap is to expand progressively sprint by sprint based on demand. If your integration needs dry-run for a specific type, contact us.
Contact and support
- Email: [email protected] (placeholder; PM confirms final address)
- Internal Slack:
#api-migrations(clients with shared channel) - Public issues: GitLab issues
- Docs: this site (
docs/vitepress/migration/exports-api-v2.en.md)