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

# Submit a bulk import job for a resource type

> Accepts up to 1000 records for one of 17 supported resource types
(accounts, products, orders, journal-entries, vendor-bills, ap-payments,
product-categories, product-variants, product-pricing-policies, product-vendors,
product-prepackaged-presets, customer-payments, inventory-balances-opening-stock,
inventory-balances-set-opening-stock, inventory-transactions, order-tax-lines,
account-addresses).
Returns 202 + job_id for async processing.
Returns 200 + dry_run result if ?dry_run=true (no DB writes made).
Idempotency-Key header deduplicates concurrent submissions.
THE ONE RULE: this endpoint is a thin loop over canonical handlers;
it does NOT write directly to any resource table.




## OpenAPI

````yaml /openapi.yaml post /entities/{entity_id}/migration/{resource}/bulk
openapi: 3.1.0
info:
  title: Arcus ERP Public API
  version: 1.0.0
  description: >
    Arcus ERP public REST API. Designed for external integrations and data
    migration.


    **Authentication.** Bearer token (API key) via the `Authorization` header.

    Format: `Authorization: Bearer ark_live_ent_<code>_<random>` (or
    `ark_test_*` for sandbox).

    API keys are issued per-entity in **Settings > Developers > API Keys**.


    **Entity scoping.** The entity is encoded in the API key prefix; routes are
    flat

    (e.g. `/v1/accounts`, `/v1/orders`, `/v1/products`). A small set of platform
    endpoints

    (migration, reconciliation, events, webhook endpoints, API keys) use the

    `/v1/entities/{entity_id}/...` form -- those are noted in their tags.


    **Key capabilities.**
      - Related-resource hydration via `?expand[]=` (see `x-arcus-expand` on each resource).
      - Cursor-based pagination (`starting_after` / `ending_before` / `limit`).
      - Idempotency via the `Idempotency-Key` header.
      - Webhook events for asynchronous notification.
      - Conditional requests / ETag for cache validation.
servers:
  - url: https://api.arcuserp.com/v1
    description: Arcus ERP API (accepts both live `ark_live_*` and test `ark_test_*` keys)
  - url: https://dev-api.arcuserp.com/v1
    description: >-
      Dev sandbox API (test-only data, accepts `ark_test_*` keys against dev
      RDS)
security: []
paths:
  /entities/{entity_id}/migration/{resource}/bulk:
    post:
      tags:
        - Migration
      summary: Submit a bulk import job for a resource type
      description: >
        Accepts up to 1000 records for one of 17 supported resource types

        (accounts, products, orders, journal-entries, vendor-bills, ap-payments,

        product-categories, product-variants, product-pricing-policies,
        product-vendors,

        product-prepackaged-presets, customer-payments,
        inventory-balances-opening-stock,

        inventory-balances-set-opening-stock, inventory-transactions,
        order-tax-lines,

        account-addresses).

        Returns 202 + job_id for async processing.

        Returns 200 + dry_run result if ?dry_run=true (no DB writes made).

        Idempotency-Key header deduplicates concurrent submissions.

        THE ONE RULE: this endpoint is a thin loop over canonical handlers;

        it does NOT write directly to any resource table.
      operationId: postMigrationBulk
      parameters:
        - name: entity_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: resource
          in: path
          required: true
          schema:
            type: string
            enum:
              - accounts
              - products
              - orders
              - journal-entries
              - vendor-bills
              - ap-payments
              - product-categories
              - product-variants
              - product-pricing-policies
              - product-vendors
              - product-prepackaged-presets
              - customer-payments
              - inventory-balances-opening-stock
              - inventory-balances-set-opening-stock
              - inventory-transactions
              - inventory-movements
              - order-tax-lines
              - account-addresses
              - account-contacts
              - purchase-orders
              - returns
              - vendor-credits
        - name: dry_run
          in: query
          required: false
          schema:
            type: boolean
          description: >
            If true, runs validation synchronously and returns results without
            any DB writes.
        - $ref: '#/components/parameters/Idempotency'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MigrationBulkRequest'
      responses:
        '200':
          description: Dry-run result (no writes made)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MigrationDryRunResult'
        '202':
          description: Job accepted for async processing
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MigrationBulkAccepted'
        '400':
          $ref: '#/components/responses/Error'
        '401':
          $ref: '#/components/responses/Error'
        '403':
          $ref: '#/components/responses/Error'
        '409':
          description: Idempotency key collision (different request body for same key)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
      security:
        - ApiKeyAuth: []
components:
  parameters:
    Idempotency:
      name: Idempotency-Key
      in: header
      required: false
      description: >
        Client-generated unique key for idempotent POST/PATCH/DELETE operations.

        Max 255 chars. On retry with the same key, the original response is
        returned

        without re-executing the operation. Keys expire after 24 hours.

        Pattern: <random-uuid> or <client-prefix>-<operation>-<resource-id>.
      schema:
        type: string
        maxLength: 255
  schemas:
    MigrationBulkRequest:
      type: object
      required:
        - records
        - external_source
      properties:
        records:
          type: array
          minItems: 1
          maxItems: 1000
          items:
            type: object
            description: >
              Resource payload matching the canonical create handler's body for
              the URL `{resource}`.

              See the canonical create endpoint (POST /v1/accounts,
              /v1/products, /v1/orders,

              /v1/journal-entries, /v1/vendor-bills, /v1/ap-payments) for the
              full accepted field set.


              **Always present (auto-injected by the dispatch loop):**
                - `entity_id` is silently dropped from the record; the value is derived from the API key's bound entity (Layer 1 isolation).
                - `external_source` from the top-level request body is stamped onto every record.
                - `migration_batch_id` from the top-level request body is stamped onto every record.
                - `imported_at` defaults to the server's now() but is overridden by any caller-supplied value (e.g. preserve the source ERP's original `created_at` for audit fidelity).

              **Migration-mode flags (auto-applied by the dispatch loop
              per-resource -- callers do NOT set):**
                - `resource=journal-entries`: `bypass_period_check=true` (allows posting to closed historical periods; gated by `migration:write` scope at the route layer).
                - `resource=vendor-bills`: `suppress_gl=true` (prevents double-posting JEs already loaded via `resource=journal-entries`; the canonical `postJournalEntry` handler is the SSOT for JE writes).
                - `resource=ap-payments`: `suppress_gl=true` AND `skip_plaid=true` (the latter critical: historical ACH payments must NEVER trigger live Plaid Transfer attempts on entities with Plaid configured).
                - `resource=purchase-orders`: `is_historical_import=true` is injected per record by the dispatch loop; `createPurchaseOrder` branches on it into its historical sister `createMigratedPurchaseOrder`, which preserves the source PO number / dates / status and writes the line set atomically (no native counter advance, no draft->open auto-transition).

              **Resource-specific record shape -- `resource=purchase-orders`:**
                Imports a settled purchase order from a prior ERP as an Arcus PO
                (`orders.document_type='purchase_order'` + `order_items` rows). A PO carries a
                VENDOR account, not a customer -- distinct from `resource=orders` (sales orders).
                Per-record fields:
                  - `account_id` (required, UUID): the Arcus vendor account.
                  - `order_number` (optional): the source PO number (e.g. Versa `PO-42122`). Preserved verbatim on `orders.order_number`; the native PO counter is NOT advanced. Omit to let the server assign a counter number.
                  - `order_date` (optional, ISO 8601): source PO date; defaults to `imported_at`.
                  - `expected_date` (optional, date): expected receipt date.
                  - `order_status` / `fulfillment_status` / `payment_status` (optional): source statuses; preserved verbatim (lower-cased for enum cast).
                  - `subtotal`, `tax_total`, `order_total`, `amount_paid`, `balance_due` (optional, numeric): source header totals. When `order_total` is supplied it is kept verbatim; otherwise the header totals are recalculated from the line set.
                  - `location_id`, `shipping_address_id` (optional, UUID): source-document links.
                  - `notes`, `internal_notes` (optional): free text.
                  - `items` (array): PO lines. Each: `product_id` (required, UUID), `quantity` (required, positive integer), `unit_cost` (optional, numeric -- per-unit cost; `sell_rate` accepted as an alias), `variant_id` (optional, UUID), `product_title` (optional, snapshot name), `tax_amount` (optional, numeric), `notes` (optional), `sort_order` (optional, integer).

              **Required per record:**
                - `external_id`: source-system primary key for this record (e.g. Versa's product_id). Required for idempotency: re-running the same bulk request with the same external_id is a no-op when `conflict_mode=skip`. The partial UNIQUE on `(entity_id, external_source, external_id) WHERE external_source IS NOT NULL` enforces this DB-side.

              **Optional per record:**
                - `external_url`: deep-link back to the source-system row (e.g. https://versa.example.com/products/1019471)
                - All other fields documented on the canonical create endpoint for the resource.

              **Resource-specific record shape --
              `resource=inventory-transactions`:**
                This resource imports the per-product movement HISTORY (sales + purchases) that the
                demand/reorder engine reads, NOT current on-hand (current on-hand is owned by
                `resource=inventory-balances-opening-stock`). It has no FIFO / balance / GL side effects.
                Because the `inventory.inventory_transactions` table has no `external_source` / `external_id`
                columns, idempotency for this resource is keyed on
                `(entity_id, source_transaction_id, reference_type)`; `external_id` is ignored.
                Per-record fields:
                  - `product_id` (required, UUID): Arcus product.
                  - `location_id` (required, UUID): Arcus location.
                  - `type` (required): an `inventory_txn_type` enum value (`sale`, `receiving`, `rma`, `transfer_out`, ...).
                  - `quantity` (required, signed integer): negative = outbound (sale/consumption), positive = inbound (receipt).
                  - `posted_at` (required, ISO 8601): the source system's posting date. This is preserved verbatim onto BOTH `posted_at` and `created_at` (the demand engine reads `created_at` inside a 90-day window).
                  - `source_transaction_id` (required, string ≤100 chars): the source system's transaction id. Idempotency key + stored in `transaction_id` + `reference_number`.
                  - `created_at` (optional): source created timestamp; defaults to `posted_at`.
                  - `reference_type` (optional): defaults `versa_migration`.
                  - `unit_price`, `total_price` (optional, numeric): per-unit and extended movement value.
                  - `supply_demand` (optional): `supply` / `demand` / `neutral`; inferred from `type` if absent.
                  - `account_id`, `order_id`, `order_item_id`, `sales_channel_id` (optional, UUID): source-document links.
                  - `notes` (optional): free text (source memo).
        external_source:
          type: string
          maxLength: 60
          description: >
            Source system identifier (e.g. "versa_cloud", "quickbooks_online",
            "netsuite", "shopify_export"). Required. Persisted on every record's
            `external_source` column. Used by GET
            /v1/entities/{entity_id}/reconciliation/counts?external_source=<value>
            to filter counts to a specific source.
        conflict_mode:
          $ref: '#/components/schemas/MigrationConflictMode'
        migration_batch_id:
          type: string
          format: uuid
          description: >
            Optional batch identifier you generate once per logical migration
            "wave" and pass on every record. Persisted on every record's
            `migration_batch_id` column. Used to group records that were
            imported together for later reconciliation or rollback.
        dry_run:
          type: boolean
          default: false
          description: >
            If true, the records are validated end-to-end but no DB writes
            occur. Useful for catching missing required fields and FK violations
            before committing a real import.
    MigrationDryRunResult:
      type: object
      description: Dry-run result (200 response for ?dry_run=true). No DB writes made.
      properties:
        object:
          type: string
          enum:
            - migration_dry_run_result
        dry_run:
          type: boolean
          enum:
            - true
        resource:
          type: string
        external_source:
          type: string
        total_records:
          type: integer
        succeeded_records:
          type: integer
        failed_records:
          type: integer
        errors:
          type: array
          items:
            type: object
    MigrationBulkAccepted:
      type: object
      description: >
        Async bulk import accepted (HTTP 202). The job has been queued and a
        background

        runner is kicked immediately, so processing typically begins within a
        second and

        all records in the batch are processed back-to-back. Poll

        `GET /v1/entities/{entity_id}/migration/jobs/{id}` for status, error
        array, and

        aggregate counts.
      required:
        - object
        - id
        - status
        - resource
        - dry_run
        - conflict_mode
        - external_source
        - total_records
        - created_at
      properties:
        object:
          type: string
          enum:
            - migration_job
        id:
          type: string
          format: uuid
          description: The migration job ID. Use this to poll `/migration/jobs/{id}`.
        status:
          type: string
          enum:
            - pending
            - in_progress
            - succeeded
            - failed
            - partial
          description: >-
            Initial value is `pending`; transitions to `in_progress` when the
            runner picks it up, then to `succeeded` / `failed` / `partial` based
            on per-record outcomes.
        resource:
          type: string
          enum:
            - accounts
            - products
            - orders
            - journal-entries
            - vendor-bills
            - ap-payments
            - product-categories
            - product-variants
            - product-pricing-policies
            - product-vendors
            - product-prepackaged-presets
            - customer-payments
            - inventory-balances-opening-stock
            - inventory-balances-set-opening-stock
            - inventory-transactions
            - order-tax-lines
            - account-addresses
            - account-contacts
            - purchase-orders
            - returns
          description: The resource path segment this job is loading.
        dry_run:
          type: boolean
          description: >-
            Reflects the `dry_run` flag from the request. When `true`, no DB
            writes; the job's final report shows what WOULD have happened.
        conflict_mode:
          $ref: '#/components/schemas/MigrationConflictMode'
        external_source:
          type: string
          description: The source-system identifier stamped on every record in this batch.
        total_records:
          type: integer
          description: Number of records queued in this batch.
        created_at:
          type: string
          format: date-time
    ErrorEnvelope:
      type: object
      description: |
        Canonical error response envelope. All API errors use this shape.
      required:
        - error
        - code
      properties:
        error:
          type: string
          description: Machine-readable error key
          example: not_found
        code:
          type: string
          description: Machine-readable error code (often same as error)
          example: not_found
        type:
          type: string
          enum:
            - validation_error
            - permission_error
            - not_found
            - conflict
            - rate_limit
            - internal
            - expand_error
            - not_implemented
          example: not_found
        hint:
          type: string
          description: Human-readable one-sentence explanation (English)
          example: >-
            The requested order does not exist or does not belong to this
            entity.
        param:
          type: string
          description: The parameter that caused the error, if applicable
          example: expand[0]
        required:
          type: string
          description: The scope required (only on insufficient_scope errors)
          example: accounts:read
        request_id:
          type: string
          description: >-
            Unique request ID for support tracing (maps to CloudWatch log
            stream)
          example: req_abc123
    MigrationConflictMode:
      type: string
      enum:
        - skip
        - update
        - replace
        - error
      default: skip
      description: >
        How to handle a record whose (external_source, external_id) already
        exists in Arcus.

        - skip: leave the existing record unchanged, count as success

        - update: call the canonical UPDATE handler with the incoming payload

        - replace: NULL out the existing record's provenance UNIQUE, then INSERT
        new record

        - error: abort the entire batch on the first collision (default if not
        specified: skip)
  responses:
    Error:
      description: Error response (400/401/403/404/409/422/429/500)
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
  securitySchemes:
    ApiKeyAuth:
      type: http
      scheme: bearer
      description: |
        API key issued per entity via Settings > Developers > API Keys.
        Each key carries scopes (e.g. orders:read, products:write).
        Bearer token format: Authorization: Bearer ark_live_ent_<code>_<random>
        Test keys use ark_test_ent_<code>_<random>. Both are issued per entity
        via Settings > Developers > API Keys.

````