# 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](https://cademi.dev/api/files.md)). `POST /imports` does not
process anything. It only registers the spreadsheet.

```json
{ "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:

```json
{
  "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](https://cademi.dev/api/operations.md)). Until the import is processed, you can
request a new analysis, which replaces the previous one.

```json
{ "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:

```json
{
  "object": "import_row",
  "id": "row_7",
  "line": 7,
  "status": "ready",
  "email": "user@example.com",
  "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:

```json
{ "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.

```json
{
  "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](https://cademi.dev/api/reference/imports.md).

## Exports

`POST /exports` requests a file with account data:

```json
{
  "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:

```json
{
  "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](https://cademi.dev/api/files.md)).

`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](https://cademi.dev/api/reference/exports.md).

## 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:

```json
{
  "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`:

```json
{ "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.

```json
{
  "object": "email_bounce",
  "id": "bnc_42",
  "user_id": "usr_42",
  "email": "user@example.com",
  "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`:

```json
{ "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](https://cademi.dev/api/reference/reports.md).

## 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](https://cademi.dev/api/events.md).

## 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 |
