Skip to main content

Overview

Two resources — packages and returns — cover the post-order lifecycle. Both follow the atomic-create pattern: a single POST creates the parent resource plus all inline children in one transactional write, returning the fully hydrated response. You never need follow-up calls to add items or purchase a label.
ResourceEndpointInline children
PackagePOST /v1/packagesitems[], optional label purchase, optional fulfill
Return (RMA)POST /v1/returnsitems[], optional auto-refund, restocking fee

Packages

Canonical scenario: package with items + label + fulfill in one POST

This request creates the package, adds two line items, purchases a Shippo label, and fulfills the package (posts GL entries, sends shipment notification) in a single atomic call.
curl -s -X POST "https://api.arcuserp.com/v1/packages" \
  -H "Authorization: Bearer ark_live_ent_wss_abc123xyz" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "order_id": "ORDER_UUID",
    "carrier_service": "ups_ground",
    "items": [
      {
        "order_item_id": "ORDER_ITEM_UUID_1",
        "quantity": 2,
        "serial_numbers": ["SN001", "SN002"]
      },
      {
        "order_item_id": "ORDER_ITEM_UUID_2",
        "quantity": 1
      }
    ],
    "buy_label": true,
    "rate_object_id": "SHIPPO_RATE_OBJECT_ID",
    "fulfill_after_label": true
  }'
import Arcus from "@arcuserp/typescript-sdk";

const client = new Arcus({ apiKey: "ark_live_ent_wss_abc123xyz" });

const pkg = await client.packages.create({
  order_id: "ORDER_UUID",
  carrier_service: "ups_ground",
  items: [
    { order_item_id: "ORDER_ITEM_UUID_1", quantity: 2, serial_numbers: ["SN001", "SN002"] },
    { order_item_id: "ORDER_ITEM_UUID_2", quantity: 1 },
  ],
  buy_label: true,
  rate_object_id: "SHIPPO_RATE_OBJECT_ID",
  fulfill_after_label: true,
});

console.log(pkg.data.label_url);       // https://api.goshippo.com/...
console.log(pkg.data.tracking_number); // 1Z999AA10123456784
console.log(pkg.data.fulfilled_at);    // 2026-05-12T...
from arcuserp import Arcus

client = Arcus(api_key="ark_live_ent_wss_abc123xyz")

pkg = client.packages.create(
    order_id="ORDER_UUID",
    carrier_service="ups_ground",
    items=[
        {"order_item_id": "ORDER_ITEM_UUID_1", "quantity": 2, "serial_numbers": ["SN001", "SN002"]},
        {"order_item_id": "ORDER_ITEM_UUID_2", "quantity": 1},
    ],
    buy_label=True,
    rate_object_id="SHIPPO_RATE_OBJECT_ID",
    fulfill_after_label=True,
)

print(pkg.data["label_url"])
print(pkg.data["tracking_number"])

Response

{
  "data": {
    "id": "PKG_UUID",
    "package_id": "SO-1234-B1",
    "order_id": "ORDER_UUID",
    "entity_id": "ENTITY_UUID",
    "carrier": "ups",
    "service": "ground",
    "status": "packed",
    "fulfillment_status": "fulfilled",
    "items": [
      {
        "id": "PKG_ITEM_UUID_1",
        "order_item_id": "ORDER_ITEM_UUID_1",
        "quantity": 2,
        "serial_numbers": ["SN001", "SN002"]
      },
      {
        "id": "PKG_ITEM_UUID_2",
        "order_item_id": "ORDER_ITEM_UUID_2",
        "quantity": 1
      }
    ],
    "label_url": "https://api.goshippo.com/labels/...",
    "tracking_number": "1Z999AA10123456784",
    "label_purchased_at": "2026-05-12T14:32:00Z",
    "fulfilled_at": "2026-05-12T14:32:05Z",
    "weight": 2.5,
    "length": 12,
    "width": 9,
    "height": 6,
    "created_at": "2026-05-12T14:31:58Z"
  }
}

Inline create options

FieldTypeDescription
order_idstringRequired. Parent sales order UUID.
items[]arrayLine items to add. Each item: order_item_id (required), quantity (required), serial_numbers[] (optional, for serialized products).
carrier_servicestringCompound carrier+service string (ups_ground, fedex_priority_overnight). Mapped to carrier + service fields automatically.
carrier + servicestringsAlternative to carrier_service. Both must be provided.
buy_labelbooleanIf true, purchase a Shippo label after package creation. Requires rate_object_id.
rate_object_idstringRequired with buy_label: true. Fetch rates via GET /v1/packages/:id/rates.
fulfill_after_labelbooleanIf true, transition the package to fulfilled after label purchase. Posts GL entries, deducts inventory, queues shipment notification. Only fires if buy_label: true and label purchase succeeds.

Separate label purchase (two-step flow)

If you prefer to separate rate shopping from package creation:
# Step 1: Create package
PKG_ID=$(curl -s -X POST ".../v1/packages" \
  -d '{"order_id": "ORDER_UUID", "items": [...]}' | jq -r '.data.id')

# Step 2: Fetch rates
RATE_ID=$(curl -s ".../v1/packages/$PKG_ID/rates" | jq -r '.data[0].object_id')

# Step 3: Buy label
curl -s -X POST ".../v1/packages/$PKG_ID/buy-label" \
  -d "{\"rate_object_id\": \"$RATE_ID\"}"

# Step 4: Fulfill
curl -s -X POST ".../v1/packages/$PKG_ID/fulfill"

Package lifecycle

draft -> packed -> shipped (fulfilled)
                -> void (label voided, returns to packed)
  • POST /v1/packages/:id/buy-label — purchase label (transitions to packed + label attached)
  • POST /v1/packages/:id/void-label — void active label, request Shippo refund
  • POST /v1/packages/:id/fulfill — mark shipped, post GL fulfillment entries
  • GET /v1/tracking-events?package_id=:id — live tracking events

Shippo not configured

If the entity has no Shippo credentials, buy_label: true returns:
{
  "error": "connector_not_configured",
  "code": "connector_not_configured",
  "type": "connector_error",
  "hint": "Shippo is not configured for this entity. Add credentials in Settings > Integrations.",
  "package": { "id": "PKG_UUID", "..." }
}
The package is created and returned in the error body so you can retry buy-label after configuring Shippo.
Webhook delivery: the package.shipped event is queued when a package is fulfilled. Real-time webhook delivery to your endpoint is available in Q3 2026. In the interim, poll GET /v1/tracking-events to check shipment status.

Adding items to an existing package

POST /v1/packages/:id/items enforces two invariants before inserting:
  1. Item-on-order — the product must be on the parent order. Off-order adds return 422 product_not_on_order.
  2. Quantity cap — requested qty must not exceed ordered_qty - already_packed_qty across ALL packages on the order. Over-cap adds return 422 package_quantity_exceeds_ordered.
Either order_item_id OR product_id may be supplied; the canonical writer resolves the matching order_item when only product_id is provided.
422 product_not_on_order
{
  "error": "product_not_on_order",
  "code": "product_not_on_order",
  "type": "validation_error",
  "param": "product_id",
  "hint": "This product is not on the parent order. To add it, edit the order first, then return to packing.",
  "product_id": "8a3f...",
  "order_id": "c7e1..."
}
422 package_quantity_exceeds_ordered
{
  "error": "package_quantity_exceeds_ordered",
  "code": "package_quantity_exceeds_ordered",
  "type": "validation_error",
  "param": "quantity",
  "hint": "You can pack at most 2 more units of this product on this order (3 already packed; 5 ordered).",
  "ordered_qty": 5,
  "already_packed_qty": 3,
  "remaining_qty": 2,
  "product_id": "8a3f..."
}
To preview what’s still packable on an order, call GET /v1/orders/:id/items?packable=true. The response includes one row per (order_id, product_id) with ordered_qty, already_packed_qty, remaining_qty.

Handling pack leftovers

When Pack with Arcus (PackPilot) cannot fit one or more items into any available box, those items are returned as leftovers rather than silently dropped. Each leftover is persisted to fulfillment.pack_leftovers and surfaced to the warehouse operator on the Pack Order page and the Order Detail > Fulfillment tab.

Listing leftovers for an order

GET /v1/fulfillment/orders/:order_id/leftovers
Returns { data: [...rows], total, unresolved_count }. Each row includes:
FieldTypeDescription
reasonstringRejection code (see table below). Always present; defaults to unknown_packpilot_rejection when PackPilot omits a code.
reason_detailobject | nullStructured dim comparison. Only present for packpilot_item_too_large. Shape: { item_dims, max_box_dims, axis_failure }.
quantitystringUnits that could not be packed.
resolved_atstring | nullISO-8601 timestamp when resolved; null if still unresolved.
resolution_actionstring | nullHow the operator resolved it: packed_manually, dismissed, backordered, or split_to_new_order.

Rejection reason codes

CodeRoot causeOperator action
packpilot_category_mismatchNo box’s Allowed Categories list includes the item’s categoryEdit a box and add the item’s category to its Allowed Categories list
packpilot_item_too_largeItem dimensions exceed every allowed box on at least one axisAdd a larger box in Products or correct the item’s shipping dimensions
packpilot_weight_exceededItem weight exceeds max_weight on every viable boxIncrease box max weight or use a sturdier box type
packpilot_no_boxesEntity has zero active box productsAdd boxes in Products
packpilot_circuit_openPackPilot circuit breaker open (too many recent errors)Retry in a few minutes or create packages manually
unknown_packpilot_rejectionPackPilot did not return a reason codeInspect the item and resolve manually

Dim comparison (packpilot_item_too_large)

When the rejection is packpilot_item_too_large, the reason_detail object provides exact dimensions so you can diagnose without a second lookup:
{
  "reason": "packpilot_item_too_large",
  "reason_detail": {
    "item_dims": [2.4, 1.4, 9.0],
    "max_box_dims": [8.0, 8.0, 4.0],
    "axis_failure": 2
  }
}
axis_failure is the index of the first failing axis (0=length, 1=width, 2=height). In the example above, index 2 means the item’s height (9.0 in) does not fit the box’s inner height (4.0 in).

Resolving a leftover

POST /v1/fulfillment/leftovers/:id/resolve
Four resolution actions are supported:
  • packed_manually — add the units to an existing package. Requires package_id. Optional partial quantity.
  • dismissed — operator override with a mandatory notes field (audit trail).
  • backordered — promote to a customer backorder. Optional quantity (partial) and expected_date.
  • split_to_new_order — create a new sales order for the full leftover quantity, linked to the original.

Returns (RMAs)

Canonical scenario: return with items + restocking fee + auto-refund

This creates the RMA, adds line items, and issues the refund in a single atomic call.
curl -s -X POST "https://api.arcuserp.com/v1/returns" \
  -H "Authorization: Bearer ark_live_ent_wss_abc123xyz" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "original_order_id": "ORDER_UUID",
    "reason": "wrong_item",
    "refund_amount": 150.00,
    "items": [
      {
        "order_item_id": "ORDER_ITEM_UUID_1",
        "quantity": 1,
        "condition": "unused"
      }
    ],
    "restocking_fee": { "type": "percentage", "value": 10 },
    "auto_refund": true,
    "refund_payment_id": "PAYMENT_UUID"
  }'
import Arcus from "@arcuserp/typescript-sdk";

const client = new Arcus({ apiKey: "ark_live_ent_wss_abc123xyz" });

const rma = await client.returns.create({
  original_order_id: "ORDER_UUID",
  reason: "wrong_item",
  refund_amount: 150.00,
  items: [
    { order_item_id: "ORDER_ITEM_UUID_1", quantity: 1, condition: "unused" },
  ],
  restocking_fee: { type: "percentage", value: 10 },
  auto_refund: true,
  refund_payment_id: "PAYMENT_UUID",
});

console.log(rma.data.return_number);     // RMA-00042
console.log(rma.data.restocking_fee);    // 15  (10% of 150)
console.log(rma.data.items.length);      // 1
console.log(rma.data.refund?.id);        // refund record id
from arcuserp import Arcus

client = Arcus(api_key="ark_live_ent_wss_abc123xyz")

rma = client.returns.create(
    original_order_id="ORDER_UUID",
    reason="wrong_item",
    refund_amount=150.00,
    items=[
        {"order_item_id": "ORDER_ITEM_UUID_1", "quantity": 1, "condition": "unused"},
    ],
    restocking_fee={"type": "percentage", "value": 10},
    auto_refund=True,
    refund_payment_id="PAYMENT_UUID",
)

print(rma.data["return_number"])  # RMA-00042
print(rma.data["restocking_fee"]) # 15

Response

{
  "data": {
    "id": "RETURN_UUID",
    "return_number": "RMA-00042",
    "entity_id": "ENTITY_UUID",
    "original_order_id": "ORDER_UUID",
    "status": "authorized",
    "reason": "wrong_item",
    "refund_amount": 150.0,
    "restocking_fee": 15.0,
    "restocking_fee_auto_applied": false,
    "items": [
      {
        "id": "RETURN_ITEM_UUID",
        "order_item_id": "ORDER_ITEM_UUID_1",
        "quantity_authorized": 1,
        "condition": "unused",
        "disposition": "pending"
      }
    ],
    "refund": {
      "id": "REFUND_UUID",
      "amount": 135.0,
      "payment_method": "card",
      "status": "succeeded"
    },
    "created_at": "2026-05-12T14:32:00Z"
  }
}

Inline create options

FieldTypeDescription
original_order_idstringOptional. Link to the originating sales order.
reasonstringFree-text reason for the return.
refund_amountnumberTotal dollar amount to refund (before restocking fee).
items[]arrayLine items to return. Fields: order_item_id (recommended), quantity (alias for quantity_authorized), condition (enum: new/used/damaged/defective).
restocking_feenumber or objectFlat dollar amount OR { "type": "percentage", "value": 10 } (10% of refund_amount). Also accepts { "type": "flat", "value": 30 }.
auto_refundbooleanIf true, issue the refund immediately after RMA creation. Requires refund_payment_id.
refund_payment_idstringRequired with auto_refund: true. The original payment UUID to refund against.
refund_methodstringOptional. original (default), store_credit, check, cash.
auto_authorizebooleanIgnored (RMA is always created in authorized state unless entity settings require inspection).
override_windowbooleanIf true, bypasses the entity’s return-window-days guard.
customer_initiatedbooleanSet to true when the return was initiated via the customer portal.

Restocking fee formats

// Flat dollar amount
"restocking_fee": 25.00

// 10% of refund_amount
"restocking_fee": { "type": "percentage", "value": 10 }

// Explicit flat type (same as flat number)
"restocking_fee": { "type": "flat", "value": 25.00 }
All three formats store the computed flat amount in returns.restocking_fee and return it as a JS number.

RMA lifecycle

authorized -> expected (label sent to customer) -> received -> inspecting? -> restocked / written_off / sent_to_vendor -> closed
authorized -> cancelled
  • POST /v1/returns/:id/receive — record inbound goods, restore inventory
  • POST /v1/returns/:id/inspect — flag for QC inspection (if entity settings require)
  • POST /v1/returns/:id/disposition — restock / write off / send to vendor
  • POST /v1/returns/:id/refund — issue explicit refund (if not done inline)
  • POST /v1/returns/:id/cancel — cancel the RMA, release holds

Receiving and disposition (separate calls)

After the physical goods arrive:
# Receive the goods (restores inventory)
curl -X POST ".../v1/returns/$RMA_ID/receive" \
  -d '{ "items": [{ "return_item_id": "ITEM_UUID", "quantity_received": 1 }] }'

# Disposition (posts COGS reversal GL entries)
curl -X POST ".../v1/returns/$RMA_ID/disposition" \
  -d '{ "items": [{ "return_item_id": "ITEM_UUID", "outcome": "restock" }] }'

Return-window enforcement

If entities.settings.return_window_days is set, returns outside the window are rejected with 422 outside_return_window. Pass override_window: true to bypass (requires returns:write scope).

Validating serials on return

For serialized products, Arcus can verify that the scanned serial was actually sold on the order being returned before persisting it. This prevents swap fraud and inventory drift.

Entity setting

Configure the validation mode in Settings > Shipping & Fulfillment > Returns, or via the API:
curl -X PATCH ".../v1/entities/$ENTITY_ID/settings" \
  -H "Authorization: Bearer $API_KEY" \
  -d '{ "settings": { "returns": { "serial_validation_mode": "hard" } } }'
ModeBehavior
offSkip validation (default, backwards-compatible).
warnValidate, log mismatches server-side, let the receive/disposition proceed.
hardValidate and reject with 422 if the serial was not on the original shipment.

Probing a serial before submit

Call POST /v1/serials/validate-for-return to check a serial before submitting the receive. The endpoint returns a verdict without blocking the caller:
curl -X POST "https://api.arcuserp.com/v1/serials/validate-for-return" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "serial_number": "SN001",
    "original_order_id": "ORDER_UUID",
    "product_id": "PRODUCT_UUID"
  }'
Response shapes:
// Serial is valid for this return
{ "data": { "valid": true, "serial_id": "SN_UUID", "status": "sold", "order_number": "SO-0042" } }

// Serial does not match the original order
{ "data": { "valid": false, "code": "serial_not_on_original_order", "hint": "Serial SN001 is linked to a different order." } }

// No original_order_id was supplied (cannot validate)
{ "data": { "valid": null, "reason": "no_original_order" } }

Error codes (hard mode)

When serial_validation_mode is hard and a receive/disposition is submitted with a mismatched serial, the endpoint returns 422 with a structured error:
CodeMeaning
serial_not_foundNo serial with that number exists for this entity + product.
serial_not_on_original_orderSerial exists but was not sold on this RMA’s source order.
serial_not_soldSerial is not in sold status (cannot be returned).
serial_already_returnedSerial has already been returned on a previous RMA.

Scopes required

OperationScope
List / read packages or returnsfulfillment:read / returns:read
Create / update / fulfill packagesfulfillment:write
Create / receive / disposition returnsreturns:write
Refund (via POST /v1/returns/:id/refund or auto_refund)returns:write + payments:write
Create exchange orderreturns:write + orders:write
Create return labelreturns:write + fulfillment:write
Capture serials (full or partial)serials:write

Serial capture during pack

Serialized products require one serial number per unit before a package can be fulfilled. Arcus supports two capture flows: full capture (all serials at once) and partial capture (progressively scan as you pick).

Full capture

Pass raw_barcodes[] in the capture request. The backend runs barcode parsing (prefix stripping, format normalization, lot extraction) automatically.
curl
curl -s -X POST "https://api.arcuserp.com/v1/serials/capture" \
  -H "Authorization: Bearer ark_live_ent_wss_abc123xyz" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "package_item_id": "PKG_ITEM_UUID",
    "product_id": "PRODUCT_UUID",
    "raw_barcodes": ["1234567890123", "9876543210987"]
  }'
Response: { data: { captured_count, total_required, remaining, serials[], partial } }.

Partial capture (N of total)

Use POST /v1/serials/capture-partial when picking units one at a time before all units are in hand. The endpoint accepts the same shape as /v1/serials/capture and returns a partial: true flag when remaining > 0.
curl
curl -s -X POST "https://api.arcuserp.com/v1/serials/capture-partial" \
  -H "Authorization: Bearer ark_live_ent_wss_abc123xyz" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "package_item_id": "PKG_ITEM_UUID",
    "product_id": "PRODUCT_UUID",
    "raw_barcodes": ["1234567890123"]
  }'
{
  "data": {
    "captured_count": 1,
    "total_required": 3,
    "remaining": 2,
    "partial": true,
    "serials": [{ "id": "...", "serial_number": "1234567890123", "status": "allocated" }]
  }
}
Call the endpoint again as each additional unit is scanned. When remaining reaches 0 and partial: false, the line is ready to fulfill.

Release a serial (before order closes)

DELETE /v1/serials/:id/release removes a serial from its package item so it can be re-scanned or re-assigned. This endpoint is blocked once the order is in a terminal state:
ConditionResponse
Order fulfilled, cancelled, archived, or expired422 serial_release_blocked_order_locked
Package fulfillment_status = fulfilled422 serial_release_blocked_order_locked
When an order is already fulfilled, use the RMA flow (POST /v1/returns) to return the serial unit rather than releasing it directly.

Error handling

All errors follow the { error, code, type, hint } envelope. Partial-success responses (e.g. package created but label failed) include the created resource in the response body alongside the error:
{
  "error": "label_purchase_failed",
  "code": "label_purchase_failed",
  "type": "connector_error",
  "hint": "Package was created but label purchase failed. Retry via POST /v1/packages/:id/buy-label.",
  "package": { "id": "PKG_UUID", "..." }
}
This lets you recover without duplicating the package — just retry buy-label using the package.id from the error body.