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 | 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
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:
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:- Count records returned from
GET /entities/{entity_id}/{resource}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 thesource_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 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 → tick migration:write. For the cutover endpoints below you’ll additionally need migration:admin.
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.
| 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/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
| 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:
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: pending → in_progress → succeeded | failed | 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:write. This endpoint is intentionally migration-only — the standard admin counter endpoint (PATCH /admin/number-counters/:type) does NOT allow settingcurrent_valuebecause 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

