Imports, exports, and reports

This guide covers three groups of resources: importing users from a spreadsheet, exporting account data to a file, and reading aggregate reports. Import rows are processed asynchronously. The API lets you create, analyze, queue, and track an import, but a successful POST never means that enrollments were granted.

Importing users from a spreadsheet

An import starts from a file you have already uploaded with purpose=import (see the files guide). POST /imports does not process anything. It only registers the spreadsheet.

{ "file_id": "file_42", "type": "students_access", "duplicate_policy": "skip" }

type accepts a single value, students_access. duplicate_policy is skip (default) or update. The response is 201 with the new import:

{
  "object": "import",
  "id": "imp_42",
  "type": "students_access",
  "status": "pending",
  "file_id": "file_42",
  "rows": { "total": 0, "ready": 0, "not_ready": 0, "ignored": 0 },
  "duplicate_policy": "skip",
  "mapping": { "status": "status", "product_external_id": "produto_id", "email": "cliente_email" },
  "current_operation_id": null,
  "failure_reason": null,
  "deleted": false,
  "revision": "...",
  "created_at": "2026-09-23T10:00:00Z",
  "updated_at": "2026-09-23T10:00:00Z"
}

status moves through pending → analyzed → processing → processed. It becomes failed when the file as a whole is rejected, for example because it is unreadable or a required column is missing, and failure_reason explains why. rows reflects the latest analysis, never the processing. To see the state of each row, including the processing result, use GET /imports/{import_id}/rows.

If imports are disabled for your account, every POST (create, analyze, process) returns 409 feature_disabled. A spreadsheet with more than 5,000 rows is rejected at creation with 422 validation_failed on the file_id field.

GET /imports uses cursor pagination and accepts the type, status, created_after, created_before, and deleted filters. Imports in the trash are excluded by default. GET /imports/{import_id} returns a single import with an ETag.

PATCH /imports/{import_id} does only two things: it changes duplicate_policy, or it restores the import from the trash with deleted: false. Send the ETag in If-Match to avoid overwriting a newer version. The duplicate policy can only change before processing; after that, the request returns 409 state_conflict. You cannot delete an import by sending deleted: true.

DELETE /imports/{import_id} moves an import that has not been processed yet to the trash. A processed import returns 409 state_conflict. Deleting an import never undoes its effects: rows already queued continue, and enrollments already granted remain in place.

Analyzing the spreadsheet

POST /imports/{import_id}/analyses reads the whole spreadsheet, row by row, as an asynchronous operation. It requires its own permission, imports.analyze, separate from the one used to create imports. The response is 202 with an import.analyze operation (see the operations guide). Until the import is processed, you can request a new analysis, which replaces the previous one.

{ "data": { "object": "operation", "id": "op_01J8Z3", "type": "import.analyze", "status": "queued", ... } }

GET /imports/{import_id}/rows lists the analyzed rows with cursor pagination ordered by row number. It accepts the status (ready, not_ready, ignored) and q filters:

{
  "object": "import_row",
  "id": "row_7",
  "line": 7,
  "status": "ready",
  "email": "[email protected]",
  "product_external_id": "curso-123",
  "errors": [],
  "processing": { "state": null, "error": null }
}

email is returned only when your credentials also have the users.read_personal permission. Without it, the key is omitted. processing reports the processing state of the row after it has been queued (queued, processed, failed, ignored). Until then, its fields are null.

Processing

POST /imports/{import_id}/processing-attempts queues the ready rows for processing through an import.process operation. It requires its own permission, imports.process, separate from creating and analyzing:

{ "mode": "initial" }

mode defaults to initial. Use mode: retry to requeue only the rows whose processing failed. The response is 202 with a processing attempt, identified by a pat_ public ID and nested under its import. A 202 confirms that the rows were queued, not that enrollments were granted.

{
  "object": "processing_attempt",
  "id": "pat_7",
  "import_id": "imp_42",
  "selection": { "mode": "initial", "rows": 350 },
  "operation_id": "op_01J8Z4",
  "status": "queued",
  "error": null,
  "requested_by": { "kind": "credential", "id": "key_9" },
  "created_at": "2026-09-23T10:05:00Z"
}

The request returns 409 state_conflict when the import has not been analyzed, when its analysis contains errors, or when it has already been processed and mode is initial. GET /imports/{import_id}/processing-attempts and GET /imports/{import_id}/processing-attempts/{attempt_id} list and retrieve only the attempts requested through the API. Processing started outside the API does not appear there. For the full schemas, see the Imports reference.

Exports

POST /exports requests a file with account data:

{
  "resource": "users",
  "columns": ["id", "name", "email", "created_at"],
  "filters": { "product_id": ["prd_42"] }
}

resource is users or user_activity. user_activity also requires user_id. Each entry in columns must belong to the fixed column catalog of the selected resource. An unknown column returns 422 validation_failed. The email, document, and phone columns contain personal data and require the users.read_personal permission. Without it, the request is rejected with permission_denied; the columns are never dropped silently. The catalog has no automatic login column under any permission. filters accepts the same keys as GET /users: product_id, tag_id, showcase_id, delivery_id, access, type, and status.

The response is 202 with the new export. Its operation_id points to the export.generate operation that generates the file:

{
  "object": "export",
  "id": "exp_42",
  "resource": "users",
  "columns": ["id", "name", "email", "created_at"],
  "filters": { "product_id": ["prd_42"], "tag_id": null, "showcase_id": null, "delivery_id": null, "access": null, "type": null, "status": null },
  "status": "processing",
  "total": null,
  "file_id": null,
  "expires_at": null,
  "consistency": { "snapshot_at": null },
  "operation_id": "op_01J8Z5",
  "failure_reason": null,
  "created_at": "2026-09-23T10:10:00Z"
}

The file is always a UTF-8 CSV, never XLS. Any cell that starts with =, +, -, or @ is prefixed with an apostrophe, so spreadsheet applications do not evaluate it as a formula. When generation finishes, status becomes completed and the export includes file_id, total, and expires_at. Files are retained for 7 days; after that, status becomes expired and file_id is null. If generation fails, status becomes failed and failure_reason explains why.

An export never includes a download URL. To download the file, call POST /files/{file_id}/download-links, which issues a link valid for up to 900 seconds, the same way as for any other file (see the files guide).

GET /exports uses cursor pagination and accepts the resource filter. GET /exports/{export_id} returns a single export. DELETE /exports/{export_id} deletes the file immediately and moves the export to the trash, so you do not have to wait for the retention period to end. For the full schemas, see the Exports reference.

Reports

GET /reports returns the static report catalog. For each report, it lists the accepted filters, the columns (personal data columns are marked with personal: true), and the data source: metrics, aggregated once a day, or live, computed at request time:

{
  "object": "report",
  "id": "users",
  "filters": [{ "name": "from", "type": "date", "required": false }, { "name": "to", "type": "date", "required": false }],
  "columns": [{ "name": "registrations", "type": "integer", "personal": false }],
  "source": "metrics",
  "freshness": "daily"
}

The support and activity reports are computed from live data and require a from/to date range of up to 90 days. In users, the range is optional and also limited to 90 days. A longer or inverted range returns 422 validation_failed.

  • GET /reports/users: summary figures and a time series of registrations and activity, {summary, series, window, consistency}. No personal data columns.
  • GET /reports/products: one row per product accessible with the current credentials, report: "products", {product_id, name, students}.
  • GET /reports/lessons: product_id is required. A product that is not accessible with the current credentials returns 404, as if it did not exist. {lesson_id, product_id, name, views}.
  • GET /reports/exams: product_id is optional. Figures are aggregated per exam, never per user. {exam_id, product_id, title, attempts, approved}.
  • GET /reports/certificates: certificates issued per product, {product_id, name, issued}.
  • GET /reports/support: message volume and response metrics per channel. The date range is required. {channels, window, consistency}.
  • GET /reports/rankings: the same points ranking as GET /gamification/rankings. Either reports.read or rankings.read grants access. Results are limited to the products and users accessible with the current credentials, and the user's name and avatar are included only when the credentials also have users.read.
  • GET /reports/email-bounces: user email addresses that bounced, with cursor pagination. It returns its own email_bounce object because bounces can be updated (see below).
  • GET /reports/activity: recent user activity (comment, question, ticket). No cursor pagination, the date range is required, and limit goes up to 100. email, document, and phone are included only with users.read_personal.
  • GET /reports/exports: export metrics for the account (summary.total, summary.by_status, latest[]). To page through exports, use GET /exports, which requires exports.read.

Every tabular row is returned as a report_row:

{ "object": "report_row", "report": "products", "product_id": "prd_42", "name": "Course X", "students": 118 }

Email bounces

By default, the list returns only bounces that have not been ignored. Set ignored=true to list the ignored ones.

{
  "object": "email_bounce",
  "id": "bnc_42",
  "user_id": "usr_42",
  "email": "[email protected]",
  "kind": "bounced",
  "last_bounce_at": "2026-09-20T08:00:00Z",
  "ignored": false,
  "revision": "..."
}

email is returned only with users.read_personal. PATCH /reports/email-bounces/{bounce_id} marks the bounce as ignored. It requires its own permission, email_bounces.ignore. To make the update conditional, send the bounce's current revision in If-Match:

{ "ignored": true }

Ignoring a bounce lets the user receive email from your account again, which is why it requires a permission separate from reports.read. The change cannot be reverted through the API. For the full schemas, see the Reports reference.

Permissions

imports.read, imports.create, imports.update, imports.delete, imports.analyze, imports.process, exports.read, exports.create, exports.delete, reports.read, rankings.read (an alternative to reports.read, only for GET /reports/rankings), and email_bounces.ignore. Personal data in exports, import rows, and reports always requires users.read_personal.

Events

import.created, import.analyzed, import.process_requested, import.deleted, export.created, export.completed, export.failed, export.deleted, and email_bounce.ignored. No event payload contains personal data. See the events guide.

Errors

CodeStatusWhen
not_found404The import, export, row, attempt, or bounce was not found or is not accessible with the current credentials
permission_denied403The credentials lack the operation's permission, including the separate ones (imports.analyze, imports.process, email_bounces.ignore, and users.read_personal for personal data columns)
feature_disabled409Imports are disabled for your account
state_conflict409The import has already been processed (delete, change the duplicate policy, or process with mode: initial), or it has not been analyzed or its analysis contains errors (process)
revision_mismatch412The If-Match revision does not match the current one
validation_failed422The spreadsheet exceeds 5,000 rows, an export column or filter is not in the catalog, a report date range exceeds 90 days or is inverted, or limit exceeds the maximum
invalid_cursor400The pagination cursor is invalid or expired

On this page