---
title: "JSON API — Imports for Laravel | Roundly"
description: "The opt-in JSON API — every route, its validated input and response, the owner resolver, upload throttling, errors and resources without class names."
url: https://roundly-consulting.com/open-source/docs/imports-for-laravel/json-api
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Imports for Laravel](https://roundly-consulting.com/open-source/docs/imports-for-laravel.md)

# JSON API

A ready-made JSON API for a JavaScript front end or a mobile app. It is off by default:

```bash
IMPORTS_ROUTES=on
```

- Prefix imports.routes.prefix (api/imports), middleware imports.routes.middleware (api, auth), route names imports.\*. Publish imports-routes to own the route file.
- The owner of a request comes from imports.routes.owner\_resolver, a ResolvesImportOwner (resolve(Request $request): ?Model) — by default the authenticated user. No owner → 403.
- Every endpoint goes through Imports::for($owner)->by($request->user()), so another owner’s session or batch is a 404 and the importer’s authorize() decides the rest — every read asks for View.
- FormRequests validate input and only validated data is used. Responses are translated in the request locale.
- Both upload endpoints are throttled by the imports-upload rate limiter: imports.routes.upload\_limit per minute (default 10, 0 = no limit), keyed by the authenticated user’s id, or the IP for a guest.

```php
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Request;
use RoundlyConsulting\Imports\Contracts\ResolvesImportOwner;

// A team-owned API: the owner is the user's current team.
final class CurrentTeamOwner implements ResolvesImportOwner
{
    public function resolve(Request $request): ?Model
    {
        return $request->user()?->currentTeam;
    }
}

// config/imports.php
'routes' => ['enabled' => 'on', 'owner_resolver' => App\Imports\CurrentTeamOwner::class],
```

## Endpoints

| Method + path | Name | Input → response |
| --- | --- | --- |
| `GET /importers` | importers.index | ImporterResource\[\] the user may view |
| `GET /importers/{importer}/template` | importers.template | format (xlsx\|csv), locale (≤ 16, \[A-Za-z\_-\]) → a file download (asks View) |
| `GET /importers/{importer}/mappings` | importers.mappings | SavedMappingResource\[\] |
| `DELETE /importers/{importer}/mappings/{name}` | importers.mappings.destroy | {"deleted": true} 200, or {"deleted": false} 404 |
| `POST /importers/{importer}/sessions` | sessions.store | multipart file (required), sheet (≤ 31) or sheet\_index (1 … 1000), delimiter (, ; \| tab), header\_row (1 … 1000), locale, mode (skip\|update\|create\_new) → ImportSessionResource, 201 · throttled |
| `POST /importers/{importer}/file-sessions` | sessions.files | files\[\] (≥ 1 file), locale → ImportSessionResource, 201 · throttled |
| `GET /sessions` | sessions.index | importer (≤ 60), status\[\] (≤ 20 SessionStatus values), per\_page (1 … 100, default 20) → paginated ImportSessionResources, newest first |
| `GET /sessions/{session}` | sessions.show | ImportSessionResource |
| `PATCH /sessions/{session}/source` | sessions.source | sheet / sheet\_index, delimiter, header\_row → ImportSessionResource |
| `DELETE /sessions/{session}` | sessions.destroy | 204 (discard) |
| `GET /sessions/{session}/mapping` | sessions.mapping | the mapping suggestion |
| `PUT /sessions/{session}/mapping` | sessions.map | mapping (header text or 0-based index → field or null) or columns (\[{column, field}\] — always by index) → ImportSessionResource |
| `POST /sessions/{session}/mapping/save` | sessions.mapping.save | name (1 … 60) → ImportSessionResource |
| `PUT /sessions/{session}/mode` | sessions.mode | mode → ImportSessionResource |
| `POST /sessions/{session}/preview` | sessions.preview.run | ImportSessionResource, 202 |
| `GET /sessions/{session}/preview` | sessions.preview | ImportPreviewResource |
| `GET /sessions/{session}/rows` | sessions.rows | status (invalid\|duplicates\|updates\|creates\|skips\|all), per\_page (1 … 100, default 50), page → paginated ImportRowResultResources |
| `GET /sessions/{session}/error-report` | sessions.error-report | format (default xlsx), locale (the report’s labels) → a file download |
| `POST /sessions/{session}/commit` | sessions.commit | ImportBatchResource, 202 |
| `GET /batches/{batch}` | batches.show | ImportBatchResource |
| `POST /batches/{batch}/cancel · /resume · /rollback` | batches.cancel · batches.resume · batches.rollback | ImportBatchResource, 202 |
| `GET /batches/{batch}/records` | batches.records | action (created\|linked\|updated), per\_page (1 … 100, default 50) → ledger rows |

GET /sessions lists the owner’s sessions the user may view — an importer they may not view is 403, an unknown one 404. It eager-loads owner, actor and batch, and a client that lost its session id finds its open previews here.

```bash
# Authenticated as your api + auth middleware expects; the prefix is api/imports
curl -F file=@assets.csv  https://app.test/api/imports/importers/assets/sessions -H 'Accept: application/json'
curl -X PUT  https://app.test/api/imports/sessions/12/mapping -H 'Content-Type: application/json' \
     -d '{"columns": [{"column": 0, "field": "asset_tag"}, {"column": 1, "field": "name"}]}'
curl -X POST https://app.test/api/imports/sessions/12/preview
curl         "https://app.test/api/imports/sessions/12/rows?status=invalid&per_page=50"
curl -X POST https://app.test/api/imports/sessions/12/commit
curl         https://app.test/api/imports/batches/5
curl -X POST https://app.test/api/imports/batches/5/rollback
```

## Errors

| Status | When |
| --- | --- |
| 422 | Request validation; a bad mapping — {message, errors}, mapping errors keyed columns.<column> / fields.<field>. Also a rejected file, a missing sheet, a mode the importer lacks, a refused or over-limit saved mapping — {message}. |
| 403 | authorize() refused, or no owner resolved. |
| 404 | An unknown importer, another owner’s or a missing session or batch, or an id that cannot be a key of the package tables — checked before any query. |
| 409 | Wrong status, a stale preview, an expired session, a pruned file, another import running, the undo window closed. |

## A session

```json
{
  "id": 12, "importer": {"key": "assets", "label": "Assets"}, "kind": "rows",
  "status": "previewed", "status_label": "Previewed", "format": "xlsx",
  "file": {"name": "assets.xlsx", "size": 18234}, "sheet": "Assets",
  "sheets": [{"name": "Assets", "index": 1, "visible": true}], "delimiter": null, "header_row": 1,
  "headers": ["Asset tag", "Name", "Column C"],
  "mapping": [{"column": 0, "field": "asset_tag"}, {"column": 1, "field": "name"}],
  "mode": "skip", "data_rows": 50,
  "counts": {"total": 50, "valid": 47, "invalid": 2, "duplicates": 1, "duplicates_in_file": 0, "matched": 1,
             "creates": 47, "updates": 0, "skips": 1, "template_rows": 0, "blank_rows": 0},
  "progress": {"processed": 50, "total": 50, "percent": 100}, "errors": 2, "batch_id": null,
  "expires_at": "2026-11-10T09:00:00+00:00", "can": {"commit": true, "rollback": false},
  "owner": {"id": 7, "type": "Team"}, "actor": {"id": 3, "type": "User"}, "created_at": "…"
}
```

Never a disk, path or lock key. The can flags follow the rules the actions enforce: commit — previewed and not expired; rollback — the run finished with no undo yet or a failed one to retry, inside the undo window.

## Other resources

- ImportPreviewResource — counts, plan {creates, updates, skips}, update\_notice (translated, null without updates), mode, unmapped\_columns (0-based indexes).
- ImportRowResultResource — row, status, outcome, label (file batches: the entry name), errors \[{field, column (letter), code, message, value}\], match (the record reference) and changes \[{field, from, to}\] for updates.
- ImportBatchResource — id, session\_id, status, progress {processed, total, percent, phase}, summary {created, linked, updated, skipped, failed, reclassified, duplicates, invalid, duration}, undo\_until, can\_rollback, rollback\_status, rollback (null until a rollback finished or failed), actor.
- ImporterResource — key, label, kind, fields \[{name, label, type, required, options, example, help, aliases, format, in\_template}\], modes, default\_mode, formats, extensions (file importers).
- SavedMappingResource — name, importer, columns (normalised header → field), updated\_at.

The mapping suggestion (GET /sessions/{session}/mapping):

```json
{"data": {
  "columns": [{"index": 0, "header": "Inventárne č.", "field": "asset_tag", "confidence": 0.98, "source": "label", "fuzzy": false}],
  "unmapped_required": [], "ignored_columns": [3], "saved_mapping": null, "complete": true
}}
```

Ledger rows (GET /batches/{batch}/records):

```json
{"data": [{"row": 2, "action": "created", "record": {"id": 41, "type": "Asset"}, "rolled_back": false, "rollback_outcome": null}],
 "meta": {"current_page": 1, "last_page": 1, "total": 47}}
```

## Reference resources — no class names over the wire

| Role | Config key | Default | Receives → output |
| --- | --- | --- | --- |
| owner | `imports.resources.owner` | `ImportOwnerResource` | The owner model → {id, type: class basename}. |
| actor | `imports.resources.actor` | `ImportActorResource` | The actor model → {id, type}. |
| record | `imports.resources.record` | `ImportRecordResource` | A RecordReference — the stored morph pair, no query; a deleted record keeps rendering → {id, type}. |

```php
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

final class TeamReference extends JsonResource
{
    public function toArray(Request $request): array
    {
        return ['id' => $this->resource->getKey(), 'name' => $this->resource->name];
    }
}

// config/imports.php
'resources' => ['owner' => App\Http\Resources\TeamReference::class],
```

A value that is not an instantiable JsonResource subclass throws InvalidImportsConfigurationException naming the key; blank falls back to the default. When your resource reads a relation, eager-load it on lists.

## Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

[More ways to support, including crypto](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/imports-for-laravel.md)
