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

# Unpack a packed-but-unshipped order

> **Authorization:** requires `orders:write` API-key scope AND the per-user
`orders.edit` permission (handler-top SoD assertion mirrors the Cognito
dispatch). System-actor calls without an identifiable principal are
denied per AICPA SoC2 CC6.1.

Returns a packed-but-unshipped order to editable state by voiding any
unshipped Shippo labels and atomically cancelling every reversible
package (status IN ('draft','packed','label_created')). Idempotent
via `Idempotency-Key`.

**Industry parallels** (retrieved 2026-05-15):
  - Shopify "Cancel fulfillment"
    (https://help.shopify.com/en/manual/fulfillment/managing-orders/canceling-orders)
  - NetSuite "Reopen Sales Order"
  - Acumatica "Unpack Shipment" (Shipments form, Actions menu)

**Workflow (atomic):**
  1. Live recompute of `eligibility.can_unpack` inside the transaction.
  2. Void unshipped Shippo labels via the canonical voidLabel helper
     (mirrors the packOrder repack branch AUDIT-009-G3 idempotent pattern).
  3. Hard-delete every package_shipment_labels + package_items +
     packages row for the order (Layer 1 entity-scoped).
  4. Release per-line `quantity_packed -> 0` on order_items.
  5. Re-flip `orders.fulfillment_status` back to `'unfulfilled'`.
  6. Append `ACTIONS.ORDER_UNPACKED` activity row + order_timeline event.
  7. Broadcast `package.deleted` (per package) + `order.unpacked` +
     `order.updated` post-commit.

After unpack, the line-item editor regains its trash + pen + Rules +
sell-rate + qty controls automatically (the gate in
`isLineItemEditAllowedClient` releases when `fulfillment_status`
returns to `'unfulfilled'`).

**Blocker codes** (422 response):
  - `nothing_to_unpack`: no packages in a reversible status exist.
    Returns HTTP 409 `already_unpacked` instead when this is the ONLY
    blocker (concurrent double-click idempotency).
  - `labels_already_shipped`: at least one package on the order has
    entered carrier custody (status IN ('shipped','delivered','returned'));
    use Returns + new SO instead.
  - `order_fulfilled`: `fulfillment_status='fulfilled'`; use Returns.
  - `order_voided` / `order_cancelled` / `order_archived` / `order_is_quote`:
    order is in a terminal state and cannot be unpacked.




## OpenAPI

````yaml /openapi.yaml post /orders/{id}/unpack
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:
  /orders/{id}/unpack:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    post:
      tags:
        - Orders
      summary: Unpack a packed-but-unshipped order
      description: >
        **Authorization:** requires `orders:write` API-key scope AND the
        per-user

        `orders.edit` permission (handler-top SoD assertion mirrors the Cognito

        dispatch). System-actor calls without an identifiable principal are

        denied per AICPA SoC2 CC6.1.


        Returns a packed-but-unshipped order to editable state by voiding any

        unshipped Shippo labels and atomically cancelling every reversible

        package (status IN ('draft','packed','label_created')). Idempotent

        via `Idempotency-Key`.


        **Industry parallels** (retrieved 2026-05-15):
          - Shopify "Cancel fulfillment"
            (https://help.shopify.com/en/manual/fulfillment/managing-orders/canceling-orders)
          - NetSuite "Reopen Sales Order"
          - Acumatica "Unpack Shipment" (Shipments form, Actions menu)

        **Workflow (atomic):**
          1. Live recompute of `eligibility.can_unpack` inside the transaction.
          2. Void unshipped Shippo labels via the canonical voidLabel helper
             (mirrors the packOrder repack branch AUDIT-009-G3 idempotent pattern).
          3. Hard-delete every package_shipment_labels + package_items +
             packages row for the order (Layer 1 entity-scoped).
          4. Release per-line `quantity_packed -> 0` on order_items.
          5. Re-flip `orders.fulfillment_status` back to `'unfulfilled'`.
          6. Append `ACTIONS.ORDER_UNPACKED` activity row + order_timeline event.
          7. Broadcast `package.deleted` (per package) + `order.unpacked` +
             `order.updated` post-commit.

        After unpack, the line-item editor regains its trash + pen + Rules +

        sell-rate + qty controls automatically (the gate in

        `isLineItemEditAllowedClient` releases when `fulfillment_status`

        returns to `'unfulfilled'`).


        **Blocker codes** (422 response):
          - `nothing_to_unpack`: no packages in a reversible status exist.
            Returns HTTP 409 `already_unpacked` instead when this is the ONLY
            blocker (concurrent double-click idempotency).
          - `labels_already_shipped`: at least one package on the order has
            entered carrier custody (status IN ('shipped','delivered','returned'));
            use Returns + new SO instead.
          - `order_fulfilled`: `fulfillment_status='fulfilled'`; use Returns.
          - `order_voided` / `order_cancelled` / `order_archived` / `order_is_quote`:
            order is in a terminal state and cannot be unpacked.
      operationId: unpackOrder
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
                  description: Operator-supplied reason; defaults to 'unpack_and_edit'.
      responses:
        '200':
          description: |
            Order unpacked. Response surfaces the package_ids_cancelled[] and
            label_voids[] arrays so the caller can confirm the carrier-side
            void outcome. label_void_errors[] (optional) lists labels that
            failed to void at Shippo (non-fatal -- the operator can retry the
            void from the shipping routes or hand-void in the Shippo dashboard).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id:
                        type: string
                        format: uuid
                      order_number:
                        type: string
                      fulfillment_status:
                        type: string
                        enum:
                          - unfulfilled
                      package_ids_cancelled:
                        type: array
                        items:
                          type: string
                          format: uuid
                      label_voids:
                        type: array
                        items:
                          type: object
                          properties:
                            label_id:
                              type: string
                              format: uuid
                            transaction_object_id:
                              type: string
                            package_id:
                              type: string
                              format: uuid
                      label_void_errors:
                        type: array
                        items:
                          type: object
                          properties:
                            label_id:
                              type: string
                              format: uuid
                            package_id:
                              type: string
                              format: uuid
                            error:
                              type: string
        '400':
          $ref: '#/components/responses/Error'
        '401':
          $ref: '#/components/responses/Error'
        '403':
          $ref: '#/components/responses/Error'
        '404':
          $ref: '#/components/responses/Error'
        '409':
          description: |
            Conflict: idempotency carve-out for `already_unpacked` (no packages
            to unpack; the order has already been unpacked or never packed).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '422':
          description: |
            Eligibility blocker: the order cannot be unpacked in its current
            state. Body contains `unpack_blockers[]` with stable code values:
            `labels_already_shipped`, `order_fulfilled`, `order_voided`,
            `order_cancelled`, `order_archived`, `order_is_quote`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
      security:
        - ApiKeyAuth: []
components:
  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: |
        Client-generated unique key for idempotent POST/PATCH/DELETE operations.
        Alias for the Idempotency parameter. Max 255 chars. On retry with the
        same key, the original response is returned without re-executing the
        operation. Keys expire after 24 hours.
      schema:
        type: string
        maxLength: 255
  responses:
    Error:
      description: Error response (400/401/403/404/409/422/429/500)
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
  schemas:
    Error:
      description: |
        Alias for ErrorEnvelope. Canonical error response shape used by all
        API endpoints. Refer to ErrorEnvelope for the full field definition.
      allOf:
        - $ref: '#/components/schemas/ErrorEnvelope'
    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
  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.

````