Skip to content

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 (GET lists, POST creates). The URI names the resource. The canonical /exports family 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 ​

DateMilestoneWhat happens
Today (T-60)Deploy with aliases activeThe 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)ReminderThe PM / tech lead sends an email + Intercom/Slack reminder to B2B integrators. Migration manual in public docs.
T-7 (2026-07-24)Final reminderFinal 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 removedThe 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 a Sunset of 2026-07-01 (earlier) because there are no production clients consuming them.

Mapping table ​

Legacy /export/* routes (24 entries) ​

#Legacy routeCanonical routeNotes
1GET /export/receivablesPOST /exports body {type:"receivables"}GET -> POST. Query params (after, before) move to the 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. Complex filters in body filters.
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. Includes typeFile in 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. Canonical name orders-siigo (was 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-discriminated.
20GET /export/dailyGET /exports/blobs/dailyGET -> GET. Sync stream.
21GET /export/monthlyGET /exports/blobs/monthlyGET -> GET. Sync stream.
22GET /export/orders/receivablesGET /exportsGET -> GET. Paginated job list.
23GET /export/orders/receivables/sseGET /exports/sseGET -> GET. Same SSE format.
24GET /export/orders/file/:fileidGET /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 routeCanonical routeNotes
25POST /reports/exportPOST /exports body {type:"reports"}Same method, body-discriminated.
26POST /reports/export/estimatePOST /exports?dryRun=true body {type:"reports"}Dry-run via query param. Only type=reports supported.
27GET /reports/export/available-sourcesGET /exports/sources?type=reportsDiscriminator as query param.
28GET /reports/export/download/:fileidGET /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/logs

6. 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:

  1. Make the POST and save the orderID.
  2. Poll GET /exports/{orderID} until status="done" or subscribe to GET /exports/sse.
  3. 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:

  1. Polling: POST /exports, save orderID, and poll GET /exports/{orderID} every 5s until done.
  2. SSE: subscribe to GET /exports/sse and react to the export.updated event with the correct orderID.
  3. Stay with the legacy alias until sunset: if your system cannot handle async, keep using GET /export/<resource> until 2026-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-warnings or 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? ​

EndpointPermission
POST /exports with type=receivables/installments/etc.ReadExport + domain permission (ReadReceivables, ReadOrder, ReadChildEntity)
POST /exports with type=reportsExportReports
POST /exports?dryRun=true with type=reportsExportReports
GET /exports/sources?type=reportsExportReports
GET /exports/blobs/{type}ReadExport + ReadReceivables
GET /exports/sseReadExport + 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_id from the response (header X-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.ts

openapi-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=false

Recommendation: 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 ./client

Swagger-codegen does not have a flag to exclude deprecated routes; you must filter the output or wait for the sunset.

General recommendation ​

  1. Today: regenerate your SDK; the code keeps compiling because legacy routes are marked deprecated but not removed.
  2. T-30: review that your production code does not use @deprecated symbols. Migrate callsites to the canonical.
  3. Post-sunset: regenerate with the clean API. The SDK will no longer have the legacy methods.

Types supporting dryRun ​

TypedryRun supportedBehavior
reportsYesReturns 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*NoReturns 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)

Next steps ​