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.

Step 1: Configure your environment

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

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

The response is HTTP 202 with the job envelope:
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.

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:

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

Dry run

Add ?dry_run=true to validate your data without writing any records:
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

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.
Single-counter form is also supported:
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:

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

1. Freeze the entity (no new writes during migration verification):
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):
In production, this initiates a database snapshot (estimated 15 minutes). Save the snapshot_id. 3. Swap (record the go-live checkpoint):
4. Verify (automated reconciliation — trial balance, AR aging, AP aging, record counts):
Check verification_result.passed: true. Save the verify_log_id. 5. Unfreeze (go live):

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

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