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:- An Arcus entity provisioned by your account manager
- An API key with
accounts:write,products:write,orders:write,inventory:write,purchasing:write,payments:writescopes - Your source data exported to a structured format (CSV, JSON, or database dump)
- 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
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
Step 3: Import records
Use the API endpoints directly. Always includeIdempotency-Key on every POST so retries are safe:
name and account_type (business, individual, lead, or vendor) are required; addresses, contacts, payment methods, and tags can ride along in the same call. The metadata object stores your source-system key on the account. The Idempotency-Key protects a retry of the same call; to detect duplicates across separate runs, keep a table that maps each source id to the Arcus id returned, or use the bulk import API below, which de-duplicates on external_id.
Step 4: Verify
After importing each resource type:- Count the records you can page through at
GET /v1/{resource}(for exampleGET /v1/accounts) vs your source - Spot-check random records for field accuracy
- Check inventory balances against your source totals
- Check AR balance against open invoices
Resuming an interrupted migration
Use your own mapping of source id to Arcusid as a checkpoint: before inserting a record, look it up in your table, and skip it if it was already imported. This makes your import script idempotent and safe to re-run. The list endpoints have no source-id filter, so for large loads use the bulk import API below, which skips a record it has already seen (same external_id) when conflict_mode is skip.
Rollback
If you need to start over, records tagged with yoursource_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 requiremigration:write scope on the API key. Generate one at Settings > Developers > API Keys > Create key, then tick migration:write. Reading job progress needs migration:read. The counter-advance endpoint below needs only migration:write. The cutover endpoint additionally needs migration:admin, so cutover calls need both migration:admin and migration:write.
Submit a batch
id and poll /migration/jobs/{id} for progress.
Per-record fields
Eachrecords[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 /v1/orders/{id}/cancel,POST /v1/orders/{id}/void,POST /v1/journal-entries/{id}/reverse,DELETE /v1/vendor-bills/{id}(voids the bill),DELETE /v1/ap-payments/{id}(voids the payment), andPOST /v1/orders/{id}/payments/{payment_id}/refundon 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:
would_create, would_update, would_skip, would_fail counts. No DB writes occur. Run a dry run for every batch before the real run, which catches schema mistakes, missing FK targets, and validation failures before they create partial state.
Check job progress
status transitions: pending, then in_progress, then succeeded, failed or partial. The response includes:
processed_records,created,updated,skipped,failedaggregate countserrors[]: per-record error array with{index, external_id, error, code, hint}created_at,started_at,completed_attimestamps
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:- Pre-migration config (set up in Settings UI before importing): GL chart of accounts, locations, payment terms, pricing levels, sales channels, product categories.
accounts: customers + vendors. Include addresses, contacts, and payment methods as nested arrays (atomic-create).products: including variants, kit components, and pricing policies as nested arrays.journal-entries: historical GL header + lines. Sub-account folding to canonical parents +entity_contact_iddimension if your source has per-customer/vendor AR/AP sub-accounts.orders: sales orders + invoices + credit memos (all variants of the unifiedorderstable; usedocument_typeto disambiguate). Include order items as nested arrays.vendor-bills: AP subledger. Include line items as nested arrays.ap-payments: vendor payments. Setbill_ids: [{external_id, amount}]when paying off bills you imported in step 6.- 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 wasINV-342665, set advance_to: 342665 and Arcus’s next issued invoice will be 342666.
- Scope: requires
migration:writeonly. This endpoint is intentionally migration-only: the standard number-counter setting does NOT allow settingcurrent_value, because doing so in live operation would create document-number gaps or collisions. - Semantics:
advance_tois the newcurrent_value, NOT the next-issued value. Next-issued will beadvance_to + 1. - Monotonic:
advance_tomust be>=the existingcurrent_value. Decrement is rejected. - Idempotent: re-running with the same
advance_tois a no-op (advanced: falsein 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
prefixin the body.paddingdefaults to 7.
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
freeze_token from the response. You will need it for the snapshot step.
2. Take a snapshot (pre-cutover database snapshot for rollback baseline):
snapshot_id.
3. Swap (record the go-live checkpoint):
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: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
current_state, frozen boolean, and the last 10 cutover log entries.
Getting help
- API errors: check
request_idin 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

