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