> ## Documentation Index
> Fetch the complete documentation index at: https://docs.arcuserp.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Migrating Data into Arcus

> Import records from a legacy ERP, spreadsheet, or other source into Arcus via the public API.

## Overview

This guide explains how to migrate your existing data into Arcus using the public API. Whether you are migrating from another ERP, importing a CSV export, or writing a custom loader, the same principles apply: use the API endpoints your integration will use in production, include idempotency keys for safe retries, and import in dependency order.

**Estimated time:** 2-4 hours for a typical dataset (under 100K records per resource type).

## Prerequisites

Before starting:

1. An Arcus entity provisioned by your account manager
2. An API key with `accounts:write`, `products:write`, `orders:write`, `inventory:write`, `purchasing:write`, `payments:write` scopes
3. Your source data exported to a structured format (CSV, JSON, or database dump)
4. Node.js 20 or later installed locally (if using the reference migration scripts)

## Migration order

Data must be imported in dependency order. Arcus enforces referential integrity; importing in the wrong order causes validation errors.

| Step | Resource                       | Depends on          |
| ---- | ------------------------------ | ------------------- |
| 1    | Locations                      | (none)              |
| 2    | Payment Terms                  | (none)              |
| 3    | Units of Measure               | (none)              |
| 4    | Product Categories             | (none)              |
| 5    | Accounts (customers + vendors) | Payment Terms       |
| 6    | Products                       | Categories, UoM     |
| 7    | Inventory balances             | Products, Locations |
| 8    | Purchase Orders                | Vendors, Products   |
| 9    | Sales Orders + Invoices        | Customers, Products |
| 10   | Payments                       | Orders              |
| 11   | Returns                        | Orders              |

## Step 1: Configure your environment

```bash theme={null}
export ARCUS_API_KEY="ark_live_ent_acme_..."
export ARCUS_ENTITY_ID="your-entity-uuid"
export ARCUS_BASE_URL="https://api.arcuserp.com/v1"
```

Start with a test-mode key (`ark_test_ent_...`) against your test data before switching to live mode.

## Step 2: Validate your source data

Before importing, validate your source records:

* Required fields are non-null
* Foreign key references can be resolved (e.g. account on orders)
* Date formats are parseable (ISO 8601)
* Numeric fields are within expected ranges

Fix validation errors before proceeding. Log a report to a file for tracking.

## Step 3: Import records

Use the API endpoints directly. Always include `Idempotency-Key` on every POST so retries are safe:

```bash theme={null}
# Create an account
curl -X POST "$ARCUS_BASE_URL/entities/$ARCUS_ENTITY_ID/accounts" \
  -H "Authorization: Bearer $ARCUS_API_KEY" \
  -H "Idempotency-Key: import-account-$(echo $SOURCE_ID | md5)" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Acme Corp",
    "account_type": "business",
    "source_system": "legacy-erp",
    "source_id": "CUST-1234"
  }'
```

The `source_system` and `source_id` fields let you track the original record and detect duplicates across runs.

## Step 4: Verify

After importing each resource type:

1. Count records returned from `GET /entities/{entity_id}/{resource}` vs your source
2. Spot-check random records for field accuracy
3. Check inventory balances against your source totals
4. Check AR balance against open invoices

## Resuming an interrupted migration

Use the `source_id` field as a checkpoint: before inserting a record, query by `source_id` to check if it was already imported. If it exists, skip it. This makes your import script idempotent and safe to re-run.

```bash theme={null}
# Check if account was already imported
curl "$ARCUS_BASE_URL/entities/$ARCUS_ENTITY_ID/accounts?source_id=CUST-1234" \
  -H "Authorization: Bearer $ARCUS_API_KEY"
```

## Rollback

If you need to start over, records tagged with your `source_system` value can be identified and deleted. Deletion is only safe before the entity goes live; contact your account manager for guidance on large-scale rollbacks.

## Bulk import API (the canonical path for any non-trivial migration)

For any dataset over a few hundred records, use the bulk import API. The bulk endpoint accepts up to 1,000 records per batch and processes them asynchronously via a background runner that picks up queued jobs roughly every minute. **You can submit many batches in sequence; each one returns a job\_id you can poll for progress.**

### Required API key scope

The bulk endpoints require `migration:write` scope on the API key. Generate one at **Settings → Developers → API Keys → Create key → tick `migration:write`**. For the cutover endpoints below you'll additionally need `migration:admin`.

### Submit a batch

```bash theme={null}
curl -X POST "$ARCUS_BASE_URL/entities/$ARCUS_ENTITY_ID/migration/accounts/bulk" \
  -H "Authorization: Bearer $ARCUS_API_KEY" \
  -H "Idempotency-Key: batch-accounts-1" \
  -H "Content-Type: application/json" \
  -d '{
    "records": [
      {
        "external_id": "versa-customer-12345",
        "display_name": "Acme Corp",
        "company_name": "Acme Corp",
        "account_type": "business",
        "account_email": "contact@acme.com"
      },
      {
        "external_id": "versa-customer-12346",
        "display_name": "Globex Inc",
        "company_name": "Globex Inc",
        "account_type": "business",
        "account_email": "info@globex.com"
      }
    ],
    "external_source": "versa_cloud",
    "migration_batch_id": "11111111-2222-3333-4444-555555555555",
    "conflict_mode": "skip"
  }'
```

The response is HTTP 202 with the job envelope:

```json theme={null}
{
  "object": "migration_job",
  "id": "e83970c9-59a6-4dbc-a429-88f42a20e248",
  "status": "pending",
  "resource": "accounts",
  "dry_run": false,
  "conflict_mode": "skip",
  "external_source": "versa_cloud",
  "total_records": 2,
  "created_at": "2026-05-12T23:56:00.000Z"
}
```

Save the `id` and poll `/migration/jobs/{id}` for progress.

### Per-record fields

Each `records[N]` is the canonical create-handler body for the resource path in the URL (`/migration/{resource}/bulk`). For example, an `accounts` record matches the body of `POST /v1/accounts`. See the resource's create endpoint docs for the full accepted field set.

| Field                      | Required | Description                                                                                                                                                                                                                              |
| -------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `external_id`              | yes      | Source-system primary key for this row. Required for idempotency. The partial UNIQUE on `(entity_id, external_source, external_id) WHERE external_source IS NOT NULL` makes re-running the same batch a no-op when `conflict_mode=skip`. |
| `external_url`             | no       | Deep-link back to the source-system row (e.g. `https://versa.example.com/products/1019471`). Useful for support and audit.                                                                                                               |
| `imported_at`              | no       | ISO timestamp. Defaults to the server's now() but is preserved if you supply your source ERP's original `created_at` (recommended for audit fidelity).                                                                                   |
| `entity_id`                | n/a      | **Silently dropped from the record.** Always derived from your API key's bound entity (Layer 1 isolation).                                                                                                                               |
| (resource-specific fields) | varies   | See the canonical create endpoint for the resource.                                                                                                                                                                                      |

### Migration-mode flags (auto-applied per resource — you don't set these)

The dispatch loop automatically applies the following safety flags per resource so historical data loads correctly:

| Resource          | Auto-applied flag                     | Why it matters                                                                                                                                                                                                                                                                                                                                                                          |
| ----------------- | ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| All resources     | `is_historical_import=true`           | **New in D.3 (2026-05-13).** Every record loaded via the bulk migration endpoint is marked as a historical import. Canonical destructive operations (cancel order, void invoice, reverse journal entry, void vendor bill, void AP payment, refund payment) return `409 historical_row_immutable` on migrated rows. Prevents accidental corruption of the reconciled migration baseline. |
| `journal-entries` | `bypass_period_check=true`            | Historical GL must be postable to closed periods. Without this, fiscal-year imports fail. Gated by `migration:write` scope at the route layer; never exposed to non-migration callers.                                                                                                                                                                                                  |
| `vendor-bills`    | `suppress_gl=true`                    | Prevents double-posting GL entries that the `journal-entries` bulk import already loaded. Bills migrate as AP subledger rows; the GL side comes from the JE bulk.                                                                                                                                                                                                                       |
| `ap-payments`     | `suppress_gl=true`, `skip_plaid=true` | Same GL rationale as bills. **`skip_plaid` is critical** -- historical ACH payments must NEVER trigger live Plaid Transfer attempts on entities with Plaid configured.                                                                                                                                                                                                                  |

### Historical-row immutability contract

Migrated rows are write-protected against destructive operations. This is intentional:

* **Why:** When you migrate historical orders, invoices, and payments, the original inventory movements and GL postings lived in your source ERP. Arcus did not track those stock movements. Cancelling a historical order would attempt to release Arcus-managed inventory that never existed there -- corrupting on-hand counts. Voiding a historical invoice would try to reverse GL entries into closed periods, breaking the reconciled opening balances.

* **What is blocked (409):** `POST /orders/:id/cancel`, `POST /orders/:id/void-invoice`, `POST /accounting/journal-entries/:id/reverse`, `DELETE /ap/bills/:id`, `POST /ap/payments/:id/void`, `POST /payments/refund` on a migrated payment.

* **What still works:** Non-destructive edits (notes, sales agent, payment terms) are still allowed. Returns on historical invoices are allowed -- but inventory restore is skipped (FIFO was never decremented in Arcus for the original sale).

* **How to handle a genuine correction to a historical row:** Create a new adjustment journal entry (for GL corrections), a credit memo (for AP corrections), or a new return RMA (inventory restore is skipped automatically for historical rows). Do not void and re-create -- that bypasses the reconciliation baseline.

* **Error format:** All guards return the same shape: `{ "error": "historical_row_immutable", "code": "historical_row_immutable", "hint": "..." }`. Your migration script should never trigger these -- if it does, you have a bug in the conflict detection or are sending the wrong row type to the wrong endpoint.

### Conflict modes

| Mode      | Behavior                                                                                                                                                                                         |
| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `skip`    | If a record with the same `(entity_id, external_source, external_id)` exists, leave it unchanged. **Recommended for migrations** — makes re-runs safe.                                           |
| `update`  | Update the existing record with the new values. Currently supported for `accounts` only; for other resources this silently degrades to `skip` with `note: 'update_not_supported'` on the result. |
| `replace` | Soft-delete the existing record (sets `external_source`/`external_id` to NULL, freeing the UNIQUE) and create a fresh one. Use sparingly — orphans the old row.                                  |
| `error`   | Any conflict aborts the batch. Use during testing to catch unexpected duplicates.                                                                                                                |

### Dry run

Add `?dry_run=true` to validate your data without writing any records:

```bash theme={null}
curl -X POST "$ARCUS_BASE_URL/entities/$ARCUS_ENTITY_ID/migration/accounts/bulk?dry_run=true" \
  -H "Authorization: Bearer $ARCUS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ ...same payload... }'
```

The response returns `would_create`, `would_update`, `would_skip`, `would_fail` counts. No DB writes occur. Run a dry run for every batch before the real run — catches schema mistakes, missing FK targets, and validation failures before they create partial state.

### Check job progress

```bash theme={null}
curl "$ARCUS_BASE_URL/entities/$ARCUS_ENTITY_ID/migration/jobs/$JOB_ID" \
  -H "Authorization: Bearer $ARCUS_API_KEY"
```

`status` transitions: `pending` → `in_progress` → `succeeded` | `failed` | `partial`. The response includes:

* `processed_records`, `created`, `updated`, `skipped`, `failed` aggregate counts
* `errors[]` — per-record error array with `{index, external_id, error, code, hint}`
* `created_at`, `started_at`, `completed_at` timestamps

### Supported resources

`accounts`, `products`, `orders`, `journal-entries`, `vendor-bills`, `ap-payments`.

### Recommended load order

A safe topological order for a full source-ERP migration into a fresh Arcus entity:

1. **Pre-migration config** (set up in Settings UI before importing): GL chart of accounts, locations, payment terms, pricing levels, sales channels, product categories.
2. **`accounts`** — customers + vendors. Include addresses, contacts, and payment methods as nested arrays (atomic-create).
3. **`products`** — including variants, kit components, and pricing policies as nested arrays.
4. **`journal-entries`** — historical GL header + lines. Sub-account folding to canonical parents + `entity_contact_id` dimension if your source has per-customer/vendor AR/AP sub-accounts.
5. **`orders`** — sales orders + invoices + credit memos (all variants of the unified `orders` table; use `document_type` to disambiguate). Include order items as nested arrays.
6. **`vendor-bills`** — AP subledger. Include line items as nested arrays.
7. **`ap-payments`** — vendor payments. Set `bill_ids: [{external_id, amount}]` when paying off bills you imported in step 6.
8. **Counter advancement** — set Arcus number\_counters past your source-system max so post-cutover transactions don't collide with imported IDs. See the next section.

### Advancing number counters before cutover

Before flipping the switch, fast-forward each Arcus counter past the corresponding source-system maximum. If your source's last invoice number was `INV-342665`, set `advance_to: 342665` and Arcus's next issued invoice will be `342666`.

```bash theme={null}
curl -X POST "$ARCUS_BASE_URL/entities/$ARCUS_ENTITY_ID/migration/counters/advance" \
  -H "Authorization: Bearer $ARCUS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "counters": [
      { "counter_type": "invoice",     "advance_to": 342665 },
      { "counter_type": "order",       "advance_to": 250000 },
      { "counter_type": "vendor_bill", "advance_to": 24044  }
    ]
  }'
```

Single-counter form is also supported:

```bash theme={null}
curl -X POST "$ARCUS_BASE_URL/entities/$ARCUS_ENTITY_ID/migration/counters/advance" \
  -H "Authorization: Bearer $ARCUS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "counter_type": "invoice", "advance_to": 342665 }'
```

**Important properties:**

* **Scope:** requires `migration:write`. This endpoint is intentionally migration-only — the standard admin counter endpoint (`PATCH /admin/number-counters/:type`) does NOT allow setting `current_value` because doing so in live operation would create document-number gaps or collisions.
* **Semantics:** `advance_to` is the new `current_value`, NOT the next-issued value. Next-issued will be `advance_to + 1`.
* **Monotonic:** `advance_to` must be `>=` the existing `current_value`. Decrement is rejected.
* **Idempotent:** re-running with the same `advance_to` is a no-op (`advanced: false` in the response).
* **Batch size:** up to 50 counters per call.
* **New counters:** if a counter row doesn't yet exist for the entity, you must include `prefix` in the body. `padding` defaults to 7.

Supported `counter_type` values: `order`, `invoice`, `quote`, `po`, `rma`, `journal`, `account`, `vendor_bill`, `ap_payment`, `check`, `transfer`, `vendor_return`, `refund`, `inventory_txn`, `work_order`, `fixed_asset`, `lease`, `lease_payment`, `deposit`, `vendor_credit`, `payment_batch`.

The response includes a `next_issued_preview` for each counter so you can sanity-check the result:

```json theme={null}
{
  "object": "migration_counter_advance_result",
  "total": 3,
  "succeeded": 3,
  "failed": 0,
  "results": [
    {
      "counter_type": "invoice",
      "previous_value": 0,
      "current_value": 342665,
      "prefix": "INV-",
      "padding": 7,
      "version": 2,
      "advanced": true,
      "next_issued_preview": "INV-0342666"
    }
  ]
}
```

***

## Cutover: switching from your old system

Once all data is imported, use the cutover API to freeze your entity, verify data integrity,
and officially go live on Arcus.

### Cutover sequence

```
freeze -> snapshot -> swap -> verify -> unfreeze
```

**1. Freeze the entity** (no new writes during migration verification):

```bash theme={null}
curl -X POST "$ARCUS_BASE_URL/entities/$ARCUS_ENTITY_ID/migration/cutover/freeze" \
  -H "Authorization: Bearer $ARCUS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "reason": "Final cutover freeze for production go-live" }'
```

Save the `freeze_token` from the response. You will need it for the snapshot step.

**2. Take a snapshot** (pre-cutover database snapshot for rollback baseline):

```bash theme={null}
curl -X POST "$ARCUS_BASE_URL/entities/$ARCUS_ENTITY_ID/migration/cutover/snapshot" \
  -H "Authorization: Bearer $ARCUS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "freeze_token": "$FREEZE_TOKEN" }'
```

In production, this initiates a database snapshot (estimated 15 minutes). Save the `snapshot_id`.

**3. Swap** (record the go-live checkpoint):

```bash theme={null}
curl -X POST "$ARCUS_BASE_URL/entities/$ARCUS_ENTITY_ID/migration/cutover/swap" \
  -H "Authorization: Bearer $ARCUS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "snapshot_id": "$SNAPSHOT_ID" }'
```

**4. Verify** (automated reconciliation -- trial balance, AR aging, AP aging, record counts):

```bash theme={null}
curl -X POST "$ARCUS_BASE_URL/entities/$ARCUS_ENTITY_ID/migration/cutover/verify" \
  -H "Authorization: Bearer $ARCUS_API_KEY"
```

Check `verification_result.passed: true`. Save the `verify_log_id`.

**5. Unfreeze** (go live):

```bash theme={null}
curl -X POST "$ARCUS_BASE_URL/entities/$ARCUS_ENTITY_ID/migration/cutover/unfreeze" \
  -H "Authorization: Bearer $ARCUS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "verify_log_id": "$VERIFY_LOG_ID" }'
```

### Rollback (if needed)

If verification fails or you encounter data issues, use the rollback endpoint to generate
operator commands for restoring to the pre-cutover state:

```bash theme={null}
curl -X POST "$ARCUS_BASE_URL/entities/$ARCUS_ENTITY_ID/migration/cutover/rollback" \
  -H "Authorization: Bearer $ARCUS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "snapshot_id": "$SNAPSHOT_ID",
    "confirm": "${ARCUS_ENTITY_ID}:rollback"
  }'
```

The response includes `instructions.data_restore_command` with the exact operator command
for restoring the database. Database restoration is always operator-manual -- the API never auto-restores.

### Check cutover status

```bash theme={null}
curl "$ARCUS_BASE_URL/entities/$ARCUS_ENTITY_ID/migration/cutover/status" \
  -H "Authorization: Bearer $ARCUS_API_KEY"
```

Returns `current_state`, `frozen` boolean, and the last 10 cutover log entries.

***

## Getting help

* API errors: check `request_id` in error responses and include it in your support ticket
* Data questions: your Arcus account manager can review your import plan
* Use the in-app Help button for migration-specific support
