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.
| Resource | Endpoint | Inline children |
|---|
| Package | POST /v1/packages | items[], optional label purchase, optional fulfill |
| Return (RMA) | POST /v1/returns | items[], 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
| Field | Type | Description |
|---|
order_id | string | Required. Parent sales order UUID. |
items[] | array | Line items to add. Each item: order_item_id (required), quantity (required), serial_numbers[] (optional, for serialized products). |
carrier_service | string | Compound carrier+service string (ups_ground, fedex_priority_overnight). Mapped to carrier + service fields automatically. |
carrier + service | strings | Alternative to carrier_service. Both must be provided. |
buy_label | boolean | If true, purchase a Shippo label after package creation. Requires rate_object_id. |
rate_object_id | string | Required with buy_label: true. Fetch rates via GET /v1/packages/:id/rates. |
fulfill_after_label | boolean | If 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
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:
- Item-on-order — the product must be on the parent order. Off-order adds return
422 product_not_on_order.
- 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.
{
"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:
| Field | Type | Description |
|---|
reason | string | Rejection code (see table below). Always present; defaults to unknown_packpilot_rejection when PackPilot omits a code. |
reason_detail | object | null | Structured dim comparison. Only present for packpilot_item_too_large. Shape: { item_dims, max_box_dims, axis_failure }. |
quantity | string | Units that could not be packed. |
resolved_at | string | null | ISO-8601 timestamp when resolved; null if still unresolved. |
resolution_action | string | null | How the operator resolved it: packed_manually, dismissed, backordered, or split_to_new_order. |
Rejection reason codes
| Code | Root cause | Operator action |
|---|
packpilot_category_mismatch | No box’s Allowed Categories list includes the item’s category | Edit a box and add the item’s category to its Allowed Categories list |
packpilot_item_too_large | Item dimensions exceed every allowed box on at least one axis | Add a larger box in Products or correct the item’s shipping dimensions |
packpilot_weight_exceeded | Item weight exceeds max_weight on every viable box | Increase box max weight or use a sturdier box type |
packpilot_no_boxes | Entity has zero active box products | Add boxes in Products |
packpilot_circuit_open | PackPilot circuit breaker open (too many recent errors) | Retry in a few minutes or create packages manually |
unknown_packpilot_rejection | PackPilot did not return a reason code | Inspect 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
| Field | Type | Description |
|---|
original_order_id | string | Optional. Link to the originating sales order. |
reason | string | Free-text reason for the return. |
refund_amount | number | Total dollar amount to refund (before restocking fee). |
items[] | array | Line items to return. Fields: order_item_id (recommended), quantity (alias for quantity_authorized), condition (enum: new/used/damaged/defective). |
restocking_fee | number or object | Flat dollar amount OR { "type": "percentage", "value": 10 } (10% of refund_amount). Also accepts { "type": "flat", "value": 30 }. |
auto_refund | boolean | If true, issue the refund immediately after RMA creation. Requires refund_payment_id. |
refund_payment_id | string | Required with auto_refund: true. The original payment UUID to refund against. |
refund_method | string | Optional. original (default), store_credit, check, cash. |
auto_authorize | boolean | Ignored (RMA is always created in authorized state unless entity settings require inspection). |
override_window | boolean | If true, bypasses the entity’s return-window-days guard. |
customer_initiated | boolean | Set to true when the return was initiated via the customer portal. |
// 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" } } }'
| Mode | Behavior |
|---|
off | Skip validation (default, backwards-compatible). |
warn | Validate, log mismatches server-side, let the receive/disposition proceed. |
hard | Validate 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:
| Code | Meaning |
|---|
serial_not_found | No serial with that number exists for this entity + product. |
serial_not_on_original_order | Serial exists but was not sold on this RMA’s source order. |
serial_not_sold | Serial is not in sold status (cannot be returned). |
serial_already_returned | Serial has already been returned on a previous RMA. |
Scopes required
| Operation | Scope |
|---|
| List / read packages or returns | fulfillment:read / returns:read |
| Create / update / fulfill packages | fulfillment:write |
| Create / receive / disposition returns | returns:write |
Refund (via POST /v1/returns/:id/refund or auto_refund) | returns:write + payments:write |
| Create exchange order | returns:write + orders:write |
| Create return label | returns: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 -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 -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:
| Condition | Response |
|---|
Order fulfilled, cancelled, archived, or expired | 422 serial_release_blocked_order_locked |
Package fulfillment_status = fulfilled | 422 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.