Skip to main content

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.
StepResourceDepends on
1Locations(none)
2Payment Terms(none)
3Units of Measure(none)
4Product Categories(none)
5Accounts (customers + vendors)Payment Terms
6ProductsCategories, UoM
7Inventory balancesProducts, Locations
8Purchase OrdersVendors, Products
9Sales Orders + InvoicesCustomers, Products
10PaymentsOrders
11ReturnsOrders

Step 1: Configure your environment

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

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:
{
  "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.
FieldRequiredDescription
external_idyesSource-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_urlnoDeep-link back to the source-system row (e.g. https://versa.example.com/products/1019471). Useful for support and audit.
imported_atnoISO 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_idn/aSilently dropped from the record. Always derived from your API key’s bound entity (Layer 1 isolation).
(resource-specific fields)variesSee 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:
ResourceAuto-applied flagWhy it matters
All resourcesis_historical_import=trueNew 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-entriesbypass_period_check=trueHistorical 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-billssuppress_gl=truePrevents 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-paymentssuppress_gl=true, skip_plaid=trueSame 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

ModeBehavior
skipIf a record with the same (entity_id, external_source, external_id) exists, leave it unchanged. Recommended for migrations — makes re-runs safe.
updateUpdate 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.
replaceSoft-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.
errorAny 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:
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

curl "$ARCUS_BASE_URL/entities/$ARCUS_ENTITY_ID/migration/jobs/$JOB_ID" \
  -H "Authorization: Bearer $ARCUS_API_KEY"
status transitions: pendingin_progresssucceeded | 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. 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.
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:
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:
{
  "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):
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):
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):
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):
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):
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:
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

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