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_idis required. A product that is not accessible with the current credentials returns404, as if it did not exist.{lesson_id, product_id, name, views}.GET /reports/exams:product_idis 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 asGET /gamification/rankings. Eitherreports.readorrankings.readgrants 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 haveusers.read.GET /reports/email-bounces: user email addresses that bounced, with cursor pagination. It returns its ownemail_bounceobject because bounces can be updated (see below).GET /reports/activity: recent user activity (comment,question,ticket). No cursor pagination, the date range is required, andlimitgoes up to 100.email,document, andphoneare included only withusers.read_personal.GET /reports/exports: export metrics for the account (summary.total,summary.by_status,latest[]). To page through exports, useGET /exports, which requiresexports.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
| Code | Status | When |
|---|---|---|
not_found | 404 | The import, export, row, attempt, or bounce was not found or is not accessible with the current credentials |
permission_denied | 403 | The 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_disabled | 409 | Imports are disabled for your account |
state_conflict | 409 | The 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_mismatch | 412 | The If-Match revision does not match the current one |
validation_failed | 422 | The 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_cursor | 400 | The pagination cursor is invalid or expired |