# Imports · Management API

> Management API reference for imports: GET /sites/{id}/imports, POST /sites/{id}/imports, GET /sites/{id}/imports/detect, GET /sites/{id}/imports/discover, ….

> Markdown version of https://kanorio.com/en/developers/management-api/imports. Append `.md` to any developer docs URL for this format. Index: https://kanorio.com/llms.txt · MCP: https://app.kanorio.com/api/mcp

Base URL: `https://app.kanorio.com/api/v1` · [Errors](https://kanorio.com/en/developers/basics/errors) · [Pagination](https://kanorio.com/en/developers/basics/pagination)

### List imports

`GET /sites/{id}/imports`

- Stability: beta
- Permission: `posts:view`
- Rate limit: default (120/minute per token)
- MCP tool: `imports_list`

List this website's recent import batches, newest first, all in one page (batches are kept for 7 days). Errors: FORBIDDEN.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | Website id. Get it from sites.list or from sites.create. |

**Response** (200)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object[] | yes | — |
| `data[].id` | string | yes | — |
| `data[].operationId` | string | yes | Pass to operations.get to poll; equals op_import_job_<id>. |
| `data[].domain` | "POSTS" \| "PRODUCTS" \| "CONTACTS" \| "COLLECTION_ITEMS" | yes | — |
| `data[].inputKind` | "URL" \| "FILE" | yes | — |
| `data[].platform` | "WORDPRESS" \| "WIX" \| "WEEBLY" \| "SQUARESPACE" \| "BLOGGER" \| "SUBSTACK" \| "GHOST" \| "MEDIUM" \| "TUMBLR" \| "WEBFLOW" \| "HUBSPOT" \| "SHOPIFY" \| "WOOCOMMERCE" \| "MAILCHIMP" \| "KLAVIYO" \| "GOOGLE_CONTACTS" \| "OUTLOOK" \| "GENERIC" \| "UNKNOWN" | yes | — |
| `data[].source` | "WP_REST" \| "SQUARESPACE_JSON" \| "BLOGGER_ATOM" \| "GHOST_API" \| "SITEMAP" \| "RSS" \| "LISTING" \| "URL_LIST" \| "WXR" \| "BLOGGER_XML" \| "FEED_FILE" \| "MEDIUM_ZIP" \| "SUBSTACK_ZIP" \| "TUMBLR_ZIP" \| "WEEBLY_ARCHIVE" \| "GHOST_JSON" \| "CSV_HTML" \| "MARKDOWN" \| "HTML" \| "CONTACTS_CSV" \| "VCARD" \| "PRODUCTS_CSV" \| "UNKNOWN" | yes | — |
| `data[].status` | "PENDING" \| "DISCOVERING" \| "PARSING" \| "REVIEW" \| "APPLYING" \| "POSTPROCESSING" \| "DONE" \| "FAILED" \| "CANCELLED" | yes | REVIEW means parsing is done and the batch waits for imports.apply: a human confirms the list (rows carry warnings such as duplicate_of, title_generated, empty_content). |
| `data[].stage` | string \| null | yes | — |
| `data[].siteUrl` | string \| null | yes | — |
| `data[].fileName` | string \| null | yes | — |
| `data[].totals` | object | yes | — |
| `data[].totals.discovered` | integer | yes | — |
| `data[].totals.selected` | integer | yes | — |
| `data[].totals.ready` | integer | yes | — |
| `data[].totals.duplicates` | integer | yes | — |
| `data[].totals.imported` | integer | yes | — |
| `data[].totals.skipped` | integer | yes | — |
| `data[].totals.failed` | integer | yes | — |
| `data[].totals.discarded` | integer | yes | — |
| `data[].totals.linksRewritten` | integer | yes | — |
| `data[].totals.pages` | integer | yes | Pages found in the source that are not imported (only posts are). |
| `data[].totals.attachments` | integer | yes | — |
| `data[].progress` | object \| null | yes | — |
| `data[].progress.current` | integer | yes | — |
| `data[].progress.total` | integer \| null | yes | — |
| `data[].errorCode` | string \| null | yes | — |
| `data[].createdAt` | string | yes | — |
| `data[].updatedAt` | string | yes | — |
| `data[].completedAt` | string \| null | yes | — |
| `data[].expiresAt` | string \| null | yes | Batch rows and the uploaded file are deleted after this time (7 days). |
| `hasMore` | boolean | yes | True when another page exists; pass nextCursor to fetch it. |
| `nextCursor` | string \| null | yes | Cursor for the next page, or null on the last page. |

**Errors**

| Code | Status | When |
| --- | --- | --- |
| `FORBIDDEN` | 403 | — |

```bash
curl -X GET "https://app.kanorio.com/api/v1/sites/{id}/imports" \
  -H "Authorization: Bearer $KANORIO_TOKEN"
```

```json
{
  "data": [
    {
      "id": "imp_example01",
      "operationId": "op_import_job_imp_example01",
      "domain": "POSTS",
      "inputKind": "URL",
      "platform": "WORDPRESS",
      "source": "WP_REST",
      "status": "DONE",
      "stage": null,
      "siteUrl": "https://old-blog.example.com",
      "fileName": null,
      "totals": {
        "discovered": 0,
        "selected": 0,
        "ready": 0,
        "duplicates": 0,
        "imported": 41,
        "skipped": 0,
        "failed": 0,
        "discarded": 0,
        "linksRewritten": 17,
        "pages": 6,
        "attachments": 120
      },
      "progress": {
        "current": 42,
        "total": 42
      },
      "errorCode": null,
      "createdAt": "2026-10-01T02:30:00.000Z",
      "updatedAt": "2026-10-06T08:00:00.000Z",
      "completedAt": "2026-10-06T08:00:00.000Z",
      "expiresAt": "2026-10-13T02:30:00.000Z"
    }
  ],
  "hasMore": false,
  "nextCursor": null
}
```

### Create an import

`POST /sites/{id}/imports`

- Stability: beta
- Permission: `blog:manage`
- Rate limit: default (120/minute per token)
- Idempotent: send `Idempotency-Key`
- MCP tool: `imports_create`

Start importing posts from an old site into this website as DRAFT posts. Give either siteUrl (the whole public post list is imported; large sites are discovered in the background and stop at REVIEW for confirmation) or urls (up to 20 specific post URLs). The batch runs in the background: poll with operations.get (operationId) or imports.get. After parsing it always stops at REVIEW: inspect rows with imports.get (failures, duplicates, generated titles, empty bodies) and call imports.apply to write the confirmed rows; nothing is written before that. Images are copied into the media library, categories/tags are reused or created, posts link to each other with the new paths, and the original publish date is kept. One active batch per site. Importing never consumes AI credits. Errors: FORBIDDEN, VALIDATION, IMPORT_IN_PROGRESS (details.batchId), IMPORT_DAILY_LIMIT_REACHED, SITE_UNREACHABLE, NO_LISTING.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | Website id. Get it from sites.list or from sites.create. |
| `Idempotency-Key` | header | string | no | Client-chosen key (1-200 chars). Retrying with the same key and the same body returns the original result; the same key with a different body is rejected with IDEMPOTENCY_CONFLICT (409). Keys expire after 24 hours. |

**Request body**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `siteUrl` | string | no | Public URL of the old site (homepage is enough), e.g. https://example.com. |
| `urls` | string[] | no | Specific post URLs instead of a whole site (max 20). |
| `keepPublishedAt` | boolean | no | Keep the original publish dates (default true). |

**Response** (201)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `batch` | object | yes | — |
| `batch.id` | string | yes | — |
| `batch.operationId` | string | yes | Pass to operations.get to poll; equals op_import_job_<id>. |
| `batch.domain` | "POSTS" \| "PRODUCTS" \| "CONTACTS" \| "COLLECTION_ITEMS" | yes | — |
| `batch.inputKind` | "URL" \| "FILE" | yes | — |
| `batch.platform` | "WORDPRESS" \| "WIX" \| "WEEBLY" \| "SQUARESPACE" \| "BLOGGER" \| "SUBSTACK" \| "GHOST" \| "MEDIUM" \| "TUMBLR" \| "WEBFLOW" \| "HUBSPOT" \| "SHOPIFY" \| "WOOCOMMERCE" \| "MAILCHIMP" \| "KLAVIYO" \| "GOOGLE_CONTACTS" \| "OUTLOOK" \| "GENERIC" \| "UNKNOWN" | yes | — |
| `batch.source` | "WP_REST" \| "SQUARESPACE_JSON" \| "BLOGGER_ATOM" \| "GHOST_API" \| "SITEMAP" \| "RSS" \| "LISTING" \| "URL_LIST" \| "WXR" \| "BLOGGER_XML" \| "FEED_FILE" \| "MEDIUM_ZIP" \| "SUBSTACK_ZIP" \| "TUMBLR_ZIP" \| "WEEBLY_ARCHIVE" \| "GHOST_JSON" \| "CSV_HTML" \| "MARKDOWN" \| "HTML" \| "CONTACTS_CSV" \| "VCARD" \| "PRODUCTS_CSV" \| "UNKNOWN" | yes | — |
| `batch.status` | "PENDING" \| "DISCOVERING" \| "PARSING" \| "REVIEW" \| "APPLYING" \| "POSTPROCESSING" \| "DONE" \| "FAILED" \| "CANCELLED" | yes | REVIEW means parsing is done and the batch waits for imports.apply: a human confirms the list (rows carry warnings such as duplicate_of, title_generated, empty_content). |
| `batch.stage` | string \| null | yes | — |
| `batch.siteUrl` | string \| null | yes | — |
| `batch.fileName` | string \| null | yes | — |
| `batch.totals` | object | yes | — |
| `batch.totals.discovered` | integer | yes | — |
| `batch.totals.selected` | integer | yes | — |
| `batch.totals.ready` | integer | yes | — |
| `batch.totals.duplicates` | integer | yes | — |
| `batch.totals.imported` | integer | yes | — |
| `batch.totals.skipped` | integer | yes | — |
| `batch.totals.failed` | integer | yes | — |
| `batch.totals.discarded` | integer | yes | — |
| `batch.totals.linksRewritten` | integer | yes | — |
| `batch.totals.pages` | integer | yes | Pages found in the source that are not imported (only posts are). |
| `batch.totals.attachments` | integer | yes | — |
| `batch.progress` | object \| null | yes | — |
| `batch.progress.current` | integer | yes | — |
| `batch.progress.total` | integer \| null | yes | — |
| `batch.errorCode` | string \| null | yes | — |
| `batch.createdAt` | string | yes | — |
| `batch.updatedAt` | string | yes | — |
| `batch.completedAt` | string \| null | yes | — |
| `batch.expiresAt` | string \| null | yes | Batch rows and the uploaded file are deleted after this time (7 days). |

**Errors**

| Code | Status | When |
| --- | --- | --- |
| `FORBIDDEN` | 403 | — |
| `VALIDATION` | 400 | — |
| `IMPORT_IN_PROGRESS` | 409 | details.batchId |
| `IMPORT_DAILY_LIMIT_REACHED` | 429 | — |
| `SITE_UNREACHABLE` | 422 | — |
| `NO_LISTING` | 422 | — |

```bash
curl -X POST "https://app.kanorio.com/api/v1/sites/{id}/imports" \
  -H "Authorization: Bearer $KANORIO_TOKEN" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{"siteUrl":"https://old-blog.example.com","keepPublishedAt":true}'
```

```json
{
  "batch": {
    "id": "imp_example01",
    "operationId": "op_import_job_imp_example01",
    "domain": "POSTS",
    "inputKind": "URL",
    "platform": "WORDPRESS",
    "source": "WP_REST",
    "status": "PENDING",
    "stage": "parse",
    "siteUrl": "https://old-blog.example.com",
    "fileName": null,
    "totals": {
      "discovered": 0,
      "selected": 42,
      "ready": 0,
      "duplicates": 0,
      "imported": 0,
      "skipped": 0,
      "failed": 0,
      "discarded": 0,
      "linksRewritten": 0,
      "pages": 6,
      "attachments": 120
    },
    "progress": null,
    "errorCode": null,
    "createdAt": "2026-10-01T02:30:00.000Z",
    "updatedAt": "2026-10-06T08:00:00.000Z",
    "completedAt": null,
    "expiresAt": "2026-10-13T02:30:00.000Z"
  }
}
```

### Detect imports

`GET /sites/{id}/imports/detect`

- Stability: beta
- Permission: `posts:view`
- Rate limit: default (120/minute per token)
- MCP tool: `imports_detect`

Identify which platform an old website runs on (WordPress, Wix, Weebly, Squarespace, Blogger, Substack, Ghost, Medium…) by fetching its homepage once. Use it to tell the user what was found before calling imports.discover or imports.create. supportsUrlDiscovery=false (Medium) means posts can only be imported from an export file in the dashboard. Errors: FORBIDDEN, VALIDATION.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | Website id. Get it from sites.list or from sites.create. |
| `url` | query | string | yes | Public URL of the old site (homepage is enough), e.g. https://example.com. |

**Response** (200)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `siteUrl` | string | yes | — |
| `platform` | "WORDPRESS" \| "WIX" \| "WEEBLY" \| "SQUARESPACE" \| "BLOGGER" \| "SUBSTACK" \| "GHOST" \| "MEDIUM" \| "TUMBLR" \| "WEBFLOW" \| "HUBSPOT" \| "SHOPIFY" \| "WOOCOMMERCE" \| "MAILCHIMP" \| "KLAVIYO" \| "GOOGLE_CONTACTS" \| "OUTLOOK" \| "GENERIC" \| "UNKNOWN" | yes | — |
| `reachable` | boolean | yes | — |
| `supportsUrlDiscovery` | boolean | yes | — |

**Errors**

| Code | Status | When |
| --- | --- | --- |
| `FORBIDDEN` | 403 | — |
| `VALIDATION` | 400 | — |

```bash
curl -X GET "https://app.kanorio.com/api/v1/sites/{id}/imports/detect?url=https%3A%2F%2Fold-blog.example.com" \
  -H "Authorization: Bearer $KANORIO_TOKEN"
```

```json
{
  "siteUrl": "https://old-blog.example.com",
  "platform": "WORDPRESS",
  "reachable": true,
  "supportsUrlDiscovery": true
}
```

### Discover imports

`GET /sites/{id}/imports/discover`

- Stability: beta
- Permission: `posts:view`
- Rate limit: default (120/minute per token)
- MCP tool: `imports_discover`

List the posts an old site exposes publicly (WordPress REST, Squarespace JSON, Blogger Atom, sitemap, RSS or listing pages, chosen per platform). Nothing is written. Returns up to 2000 items; when the site is large or slow, mode=background is returned and imports.create should be called with the siteUrl so the worker discovers in the background. Errors: FORBIDDEN, VALIDATION, SITE_UNREACHABLE, NO_LISTING (no public post list; use an export file in the dashboard).

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | Website id. Get it from sites.list or from sites.create. |
| `url` | query | string | yes | Public URL of the old site (homepage is enough), e.g. https://example.com. |

**Response** (200)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `mode` | "list" \| "background" | yes | — |
| `platform` | "WORDPRESS" \| "WIX" \| "WEEBLY" \| "SQUARESPACE" \| "BLOGGER" \| "SUBSTACK" \| "GHOST" \| "MEDIUM" \| "TUMBLR" \| "WEBFLOW" \| "HUBSPOT" \| "SHOPIFY" \| "WOOCOMMERCE" \| "MAILCHIMP" \| "KLAVIYO" \| "GOOGLE_CONTACTS" \| "OUTLOOK" \| "GENERIC" \| "UNKNOWN" | yes | — |
| `source` | "WP_REST" \| "SQUARESPACE_JSON" \| "BLOGGER_ATOM" \| "GHOST_API" \| "SITEMAP" \| "RSS" \| "LISTING" \| "URL_LIST" \| "WXR" \| "BLOGGER_XML" \| "FEED_FILE" \| "MEDIUM_ZIP" \| "SUBSTACK_ZIP" \| "TUMBLR_ZIP" \| "WEEBLY_ARCHIVE" \| "GHOST_JSON" \| "CSV_HTML" \| "MARKDOWN" \| "HTML" \| "CONTACTS_CSV" \| "VCARD" \| "PRODUCTS_CSV" \| "UNKNOWN" \| null | yes | — |
| `items` | object[] | yes | — |
| `items[].sourceKey` | string | yes | — |
| `items[].url` | string | yes | — |
| `items[].title` | string | no | — |
| `items[].publishedAt` | string | no | — |
| `items[].kind` | "post" \| "page" \| "attachment" \| "other" | yes | — |
| `truncated` | boolean | yes | — |
| `estimated` | integer \| null | yes | When mode=background: how many posts were seen before stopping. |
| `warnings` | string[] | yes | — |

**Errors**

| Code | Status | When |
| --- | --- | --- |
| `FORBIDDEN` | 403 | — |
| `VALIDATION` | 400 | — |
| `SITE_UNREACHABLE` | 422 | — |
| `NO_LISTING` | 422 | no public post list; use an export file in the dashboard |

```bash
curl -X GET "https://app.kanorio.com/api/v1/sites/{id}/imports/discover?url=https%3A%2F%2Fold-blog.example.com" \
  -H "Authorization: Bearer $KANORIO_TOKEN"
```

```json
{
  "mode": "list",
  "platform": "WORDPRESS",
  "source": "WP_REST",
  "items": [
    {
      "sourceKey": "https://old-blog.example.com/2024/05/how-we-feed-our-starter",
      "url": "https://old-blog.example.com/2024/05/how-we-feed-our-starter",
      "title": "How we feed our sourdough starter",
      "publishedAt": "2024-05-01T12:00:00.000Z",
      "kind": "post"
    },
    {
      "sourceKey": "https://old-blog.example.com/2024/06/untitled-draft",
      "url": "https://old-blog.example.com/2024/06/untitled-draft",
      "kind": "post"
    }
  ],
  "truncated": false,
  "estimated": null,
  "warnings": []
}
```

### Retrieve an import

`GET /sites/{id}/imports/{batchId}`

- Stability: beta
- Permission: `posts:view`
- Rate limit: default (120/minute per token)
- MCP tool: `imports_get`

Get one import batch with its rows (title, status, warnings, created post id and path). Row bodies are never returned. Filter rows with statuses (e.g. DUPLICATE,FAILED); rows is a page, pass limit and cursor (rows.nextCursor) to continue. Errors: FORBIDDEN, IMPORT_BATCH_NOT_FOUND, INVALID_CURSOR.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | Website id. Get it from sites.list or from sites.create. |
| `batchId` | path | string | yes | Import batch id. Get it from imports.create or imports.list. |
| `statuses` | query | "DISCOVERED" \| "SELECTED" \| "READY" \| "INCOMPLETE" \| "DUPLICATE" \| "IMPORTED" \| "SKIPPED" \| "FAILED" \| "DISCARDED"[] | no | — |
| `limit` | query | integer | no | Maximum number of items to return, 1–100. Defaults to 20. |
| `cursor` | query | string | no | Opaque cursor from the previous page's nextCursor. Omit for the first page. |

**Response** (200)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `batch` | object | yes | — |
| `batch.id` | string | yes | — |
| `batch.operationId` | string | yes | Pass to operations.get to poll; equals op_import_job_<id>. |
| `batch.domain` | "POSTS" \| "PRODUCTS" \| "CONTACTS" \| "COLLECTION_ITEMS" | yes | — |
| `batch.inputKind` | "URL" \| "FILE" | yes | — |
| `batch.platform` | "WORDPRESS" \| "WIX" \| "WEEBLY" \| "SQUARESPACE" \| "BLOGGER" \| "SUBSTACK" \| "GHOST" \| "MEDIUM" \| "TUMBLR" \| "WEBFLOW" \| "HUBSPOT" \| "SHOPIFY" \| "WOOCOMMERCE" \| "MAILCHIMP" \| "KLAVIYO" \| "GOOGLE_CONTACTS" \| "OUTLOOK" \| "GENERIC" \| "UNKNOWN" | yes | — |
| `batch.source` | "WP_REST" \| "SQUARESPACE_JSON" \| "BLOGGER_ATOM" \| "GHOST_API" \| "SITEMAP" \| "RSS" \| "LISTING" \| "URL_LIST" \| "WXR" \| "BLOGGER_XML" \| "FEED_FILE" \| "MEDIUM_ZIP" \| "SUBSTACK_ZIP" \| "TUMBLR_ZIP" \| "WEEBLY_ARCHIVE" \| "GHOST_JSON" \| "CSV_HTML" \| "MARKDOWN" \| "HTML" \| "CONTACTS_CSV" \| "VCARD" \| "PRODUCTS_CSV" \| "UNKNOWN" | yes | — |
| `batch.status` | "PENDING" \| "DISCOVERING" \| "PARSING" \| "REVIEW" \| "APPLYING" \| "POSTPROCESSING" \| "DONE" \| "FAILED" \| "CANCELLED" | yes | REVIEW means parsing is done and the batch waits for imports.apply: a human confirms the list (rows carry warnings such as duplicate_of, title_generated, empty_content). |
| `batch.stage` | string \| null | yes | — |
| `batch.siteUrl` | string \| null | yes | — |
| `batch.fileName` | string \| null | yes | — |
| `batch.totals` | object | yes | — |
| `batch.totals.discovered` | integer | yes | — |
| `batch.totals.selected` | integer | yes | — |
| `batch.totals.ready` | integer | yes | — |
| `batch.totals.duplicates` | integer | yes | — |
| `batch.totals.imported` | integer | yes | — |
| `batch.totals.skipped` | integer | yes | — |
| `batch.totals.failed` | integer | yes | — |
| `batch.totals.discarded` | integer | yes | — |
| `batch.totals.linksRewritten` | integer | yes | — |
| `batch.totals.pages` | integer | yes | Pages found in the source that are not imported (only posts are). |
| `batch.totals.attachments` | integer | yes | — |
| `batch.progress` | object \| null | yes | — |
| `batch.progress.current` | integer | yes | — |
| `batch.progress.total` | integer \| null | yes | — |
| `batch.errorCode` | string \| null | yes | — |
| `batch.createdAt` | string | yes | — |
| `batch.updatedAt` | string | yes | — |
| `batch.completedAt` | string \| null | yes | — |
| `batch.expiresAt` | string \| null | yes | Batch rows and the uploaded file are deleted after this time (7 days). |
| `rows` | object | yes | — |
| `rows.data` | object[] | yes | — |
| `rows.data[].id` | string | yes | — |
| `rows.data[].index` | integer | yes | — |
| `rows.data[].sourceKey` | string | yes | Source identity: the normalized URL for URL imports, or a prefixed key such as wxr:<guid> for files. |
| `rows.data[].title` | string \| null | yes | — |
| `rows.data[].url` | string \| null | yes | — |
| `rows.data[].status` | "DISCOVERED" \| "SELECTED" \| "READY" \| "INCOMPLETE" \| "DUPLICATE" \| "IMPORTED" \| "SKIPPED" \| "FAILED" \| "DISCARDED" | yes | — |
| `rows.data[].warnings` | string[] | yes | — |
| `rows.data[].targetId` | string \| null | yes | The created post id once IMPORTED. |
| `rows.data[].targetPath` | string \| null | yes | Public path of the created post, e.g. /blog/my-post. |
| `rows.data[].errorCode` | string \| null | yes | — |
| `rows.hasMore` | boolean | yes | True when another page exists; pass nextCursor to fetch it. |
| `rows.nextCursor` | string \| null | yes | Cursor for the next page, or null on the last page. |

**Errors**

| Code | Status | When |
| --- | --- | --- |
| `FORBIDDEN` | 403 | — |
| `IMPORT_BATCH_NOT_FOUND` | 404 | — |
| `INVALID_CURSOR` | 400 | — |

```bash
curl -X GET "https://app.kanorio.com/api/v1/sites/{id}/imports/{batchId}" \
  -H "Authorization: Bearer $KANORIO_TOKEN"
```

```json
{
  "batch": {
    "id": "imp_example01",
    "operationId": "op_import_job_imp_example01",
    "domain": "POSTS",
    "inputKind": "URL",
    "platform": "WORDPRESS",
    "source": "WP_REST",
    "status": "DONE",
    "stage": null,
    "siteUrl": "https://old-blog.example.com",
    "fileName": null,
    "totals": {
      "discovered": 0,
      "selected": 0,
      "ready": 0,
      "duplicates": 0,
      "imported": 41,
      "skipped": 0,
      "failed": 0,
      "discarded": 0,
      "linksRewritten": 17,
      "pages": 6,
      "attachments": 120
    },
    "progress": {
      "current": 42,
      "total": 42
    },
    "errorCode": null,
    "createdAt": "2026-10-01T02:30:00.000Z",
    "updatedAt": "2026-10-06T08:00:00.000Z",
    "completedAt": "2026-10-06T08:00:00.000Z",
    "expiresAt": "2026-10-13T02:30:00.000Z"
  },
  "rows": {
    "data": [
      {
        "id": "row_example01",
        "index": 0,
        "sourceKey": "https://old-blog.example.com/2024/05/how-we-feed-our-starter",
        "title": "How we feed our sourdough starter",
        "url": "https://old-blog.example.com/2024/05/how-we-feed-our-starter",
        "status": "IMPORTED",
        "warnings": [],
        "targetId": "post_example01",
        "targetPath": "/blog/how-we-feed-our-starter",
        "errorCode": null
      },
      {
        "id": "row_example02",
        "index": 1,
        "sourceKey": "https://old-blog.example.com/2024/06/untitled-draft",
        "title": null,
        "url": "https://old-blog.example.com/2024/06/untitled-draft",
        "status": "READY",
        "warnings": [
          "title_generated",
          "no_published_date"
        ],
        "targetId": null,
        "targetPath": null,
        "errorCode": null
      }
    ],
    "hasMore": true,
    "nextCursor": "eyJvIjoyMCwicyI6IiJ9"
  }
}
```

### Apply an import

`POST /sites/{id}/imports/{batchId}/apply`

- Stability: beta
- Permission: `blog:manage`
- Rate limit: default (120/minute per token)
- MCP tool: `imports_apply`

Confirm a batch that stopped at REVIEW: write the READY rows (and any DUPLICATE rows you explicitly pass in rowIds). Omit rowIds to import every READY row; rows you leave out are discarded. For background-discovered batches, rowIds selects which discovered posts to fetch and import. Rows with warnings (title_generated, empty_content) are written as-is; edit the drafts afterwards. Errors: FORBIDDEN, IMPORT_BATCH_NOT_FOUND, IMPORT_INVALID_STATE (batch is not in REVIEW).

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | Website id. Get it from sites.list or from sites.create. |
| `batchId` | path | string | yes | Import batch id. Get it from imports.create or imports.list. |

**Request body**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `rowIds` | string[] | no | — |

**Response** (200)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `batch` | object | yes | — |
| `batch.id` | string | yes | — |
| `batch.operationId` | string | yes | Pass to operations.get to poll; equals op_import_job_<id>. |
| `batch.domain` | "POSTS" \| "PRODUCTS" \| "CONTACTS" \| "COLLECTION_ITEMS" | yes | — |
| `batch.inputKind` | "URL" \| "FILE" | yes | — |
| `batch.platform` | "WORDPRESS" \| "WIX" \| "WEEBLY" \| "SQUARESPACE" \| "BLOGGER" \| "SUBSTACK" \| "GHOST" \| "MEDIUM" \| "TUMBLR" \| "WEBFLOW" \| "HUBSPOT" \| "SHOPIFY" \| "WOOCOMMERCE" \| "MAILCHIMP" \| "KLAVIYO" \| "GOOGLE_CONTACTS" \| "OUTLOOK" \| "GENERIC" \| "UNKNOWN" | yes | — |
| `batch.source` | "WP_REST" \| "SQUARESPACE_JSON" \| "BLOGGER_ATOM" \| "GHOST_API" \| "SITEMAP" \| "RSS" \| "LISTING" \| "URL_LIST" \| "WXR" \| "BLOGGER_XML" \| "FEED_FILE" \| "MEDIUM_ZIP" \| "SUBSTACK_ZIP" \| "TUMBLR_ZIP" \| "WEEBLY_ARCHIVE" \| "GHOST_JSON" \| "CSV_HTML" \| "MARKDOWN" \| "HTML" \| "CONTACTS_CSV" \| "VCARD" \| "PRODUCTS_CSV" \| "UNKNOWN" | yes | — |
| `batch.status` | "PENDING" \| "DISCOVERING" \| "PARSING" \| "REVIEW" \| "APPLYING" \| "POSTPROCESSING" \| "DONE" \| "FAILED" \| "CANCELLED" | yes | REVIEW means parsing is done and the batch waits for imports.apply: a human confirms the list (rows carry warnings such as duplicate_of, title_generated, empty_content). |
| `batch.stage` | string \| null | yes | — |
| `batch.siteUrl` | string \| null | yes | — |
| `batch.fileName` | string \| null | yes | — |
| `batch.totals` | object | yes | — |
| `batch.totals.discovered` | integer | yes | — |
| `batch.totals.selected` | integer | yes | — |
| `batch.totals.ready` | integer | yes | — |
| `batch.totals.duplicates` | integer | yes | — |
| `batch.totals.imported` | integer | yes | — |
| `batch.totals.skipped` | integer | yes | — |
| `batch.totals.failed` | integer | yes | — |
| `batch.totals.discarded` | integer | yes | — |
| `batch.totals.linksRewritten` | integer | yes | — |
| `batch.totals.pages` | integer | yes | Pages found in the source that are not imported (only posts are). |
| `batch.totals.attachments` | integer | yes | — |
| `batch.progress` | object \| null | yes | — |
| `batch.progress.current` | integer | yes | — |
| `batch.progress.total` | integer \| null | yes | — |
| `batch.errorCode` | string \| null | yes | — |
| `batch.createdAt` | string | yes | — |
| `batch.updatedAt` | string | yes | — |
| `batch.completedAt` | string \| null | yes | — |
| `batch.expiresAt` | string \| null | yes | Batch rows and the uploaded file are deleted after this time (7 days). |

**Errors**

| Code | Status | When |
| --- | --- | --- |
| `FORBIDDEN` | 403 | — |
| `IMPORT_BATCH_NOT_FOUND` | 404 | — |
| `IMPORT_INVALID_STATE` | 409 | batch is not in REVIEW |

```bash
curl -X POST "https://app.kanorio.com/api/v1/sites/{id}/imports/{batchId}/apply" \
  -H "Authorization: Bearer $KANORIO_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"rowIds":["row_example01"]}'
```

```json
{
  "batch": {
    "id": "imp_example01",
    "operationId": "op_import_job_imp_example01",
    "domain": "POSTS",
    "inputKind": "URL",
    "platform": "WORDPRESS",
    "source": "WP_REST",
    "status": "PENDING",
    "stage": "apply",
    "siteUrl": "https://old-blog.example.com",
    "fileName": null,
    "totals": {
      "discovered": 0,
      "selected": 0,
      "ready": 0,
      "duplicates": 0,
      "imported": 41,
      "skipped": 0,
      "failed": 0,
      "discarded": 0,
      "linksRewritten": 17,
      "pages": 6,
      "attachments": 120
    },
    "progress": {
      "current": 42,
      "total": 42
    },
    "errorCode": null,
    "createdAt": "2026-10-01T02:30:00.000Z",
    "updatedAt": "2026-10-06T08:00:00.000Z",
    "completedAt": null,
    "expiresAt": "2026-10-13T02:30:00.000Z"
  }
}
```

### Cancel an import

`POST /sites/{id}/imports/{batchId}/cancel`

- Stability: beta
- Permission: `blog:manage`
- Rate limit: default (120/minute per token)
- MCP tool: `imports_cancel`

Cancel an active import batch between rows. Posts already written stay as drafts; delete them from the posts list if unwanted. Errors: FORBIDDEN, IMPORT_BATCH_NOT_FOUND, IMPORT_INVALID_STATE.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | Website id. Get it from sites.list or from sites.create. |
| `batchId` | path | string | yes | Import batch id. Get it from imports.create or imports.list. |

**Response** (200)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `batch` | object | yes | — |
| `batch.id` | string | yes | — |
| `batch.operationId` | string | yes | Pass to operations.get to poll; equals op_import_job_<id>. |
| `batch.domain` | "POSTS" \| "PRODUCTS" \| "CONTACTS" \| "COLLECTION_ITEMS" | yes | — |
| `batch.inputKind` | "URL" \| "FILE" | yes | — |
| `batch.platform` | "WORDPRESS" \| "WIX" \| "WEEBLY" \| "SQUARESPACE" \| "BLOGGER" \| "SUBSTACK" \| "GHOST" \| "MEDIUM" \| "TUMBLR" \| "WEBFLOW" \| "HUBSPOT" \| "SHOPIFY" \| "WOOCOMMERCE" \| "MAILCHIMP" \| "KLAVIYO" \| "GOOGLE_CONTACTS" \| "OUTLOOK" \| "GENERIC" \| "UNKNOWN" | yes | — |
| `batch.source` | "WP_REST" \| "SQUARESPACE_JSON" \| "BLOGGER_ATOM" \| "GHOST_API" \| "SITEMAP" \| "RSS" \| "LISTING" \| "URL_LIST" \| "WXR" \| "BLOGGER_XML" \| "FEED_FILE" \| "MEDIUM_ZIP" \| "SUBSTACK_ZIP" \| "TUMBLR_ZIP" \| "WEEBLY_ARCHIVE" \| "GHOST_JSON" \| "CSV_HTML" \| "MARKDOWN" \| "HTML" \| "CONTACTS_CSV" \| "VCARD" \| "PRODUCTS_CSV" \| "UNKNOWN" | yes | — |
| `batch.status` | "PENDING" \| "DISCOVERING" \| "PARSING" \| "REVIEW" \| "APPLYING" \| "POSTPROCESSING" \| "DONE" \| "FAILED" \| "CANCELLED" | yes | REVIEW means parsing is done and the batch waits for imports.apply: a human confirms the list (rows carry warnings such as duplicate_of, title_generated, empty_content). |
| `batch.stage` | string \| null | yes | — |
| `batch.siteUrl` | string \| null | yes | — |
| `batch.fileName` | string \| null | yes | — |
| `batch.totals` | object | yes | — |
| `batch.totals.discovered` | integer | yes | — |
| `batch.totals.selected` | integer | yes | — |
| `batch.totals.ready` | integer | yes | — |
| `batch.totals.duplicates` | integer | yes | — |
| `batch.totals.imported` | integer | yes | — |
| `batch.totals.skipped` | integer | yes | — |
| `batch.totals.failed` | integer | yes | — |
| `batch.totals.discarded` | integer | yes | — |
| `batch.totals.linksRewritten` | integer | yes | — |
| `batch.totals.pages` | integer | yes | Pages found in the source that are not imported (only posts are). |
| `batch.totals.attachments` | integer | yes | — |
| `batch.progress` | object \| null | yes | — |
| `batch.progress.current` | integer | yes | — |
| `batch.progress.total` | integer \| null | yes | — |
| `batch.errorCode` | string \| null | yes | — |
| `batch.createdAt` | string | yes | — |
| `batch.updatedAt` | string | yes | — |
| `batch.completedAt` | string \| null | yes | — |
| `batch.expiresAt` | string \| null | yes | Batch rows and the uploaded file are deleted after this time (7 days). |

**Errors**

| Code | Status | When |
| --- | --- | --- |
| `FORBIDDEN` | 403 | — |
| `IMPORT_BATCH_NOT_FOUND` | 404 | — |
| `IMPORT_INVALID_STATE` | 409 | — |

```bash
curl -X POST "https://app.kanorio.com/api/v1/sites/{id}/imports/{batchId}/cancel" \
  -H "Authorization: Bearer $KANORIO_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

```json
{
  "batch": {
    "id": "imp_example01",
    "operationId": "op_import_job_imp_example01",
    "domain": "POSTS",
    "inputKind": "URL",
    "platform": "WORDPRESS",
    "source": "WP_REST",
    "status": "CANCELLED",
    "stage": null,
    "siteUrl": "https://old-blog.example.com",
    "fileName": null,
    "totals": {
      "discovered": 0,
      "selected": 0,
      "ready": 0,
      "duplicates": 0,
      "imported": 41,
      "skipped": 0,
      "failed": 0,
      "discarded": 0,
      "linksRewritten": 17,
      "pages": 6,
      "attachments": 120
    },
    "progress": {
      "current": 42,
      "total": 42
    },
    "errorCode": null,
    "createdAt": "2026-10-01T02:30:00.000Z",
    "updatedAt": "2026-10-06T08:00:00.000Z",
    "completedAt": null,
    "expiresAt": "2026-10-13T02:30:00.000Z"
  }
}
```
