Overview
Two resources — packages and returns — cover the post-order lifecycle. Both follow the atomic-create pattern: a singlePOST 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.
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.Response
POST /v1/packages returns 409 revision_unacknowledged when the order was revised after the customer received it and the customer has not yet acknowledged the new total. Acknowledge the revision with POST /v1/orders/:id/customer-acknowledge-revision first.
Inline create options
Separate label purchase (two-step flow)
If you prefer to separate rate shopping from package creation:Package lifecycle
GET /v1/packages/:id/rates— live carrier rates, cheapest first, to get therate_object_id(needsfulfillment:read)POST /v1/packages/:id/buy-label— purchase the label with a previously fetched rate (needsfulfillment:write)POST /v1/packages/:id/void-label— void the active label with the carrier; returns422and leaves the label in place if the carrier void call failsPOST /v1/packages/:id/fulfill— mark the package fulfilled, post the cost-of-goods, revenue, tax, shipping and discount journal entries, update any connected marketplace tracking, and send the shipment emailGET /v1/tracking-events?package_id=:id— carrier tracking events; apackage_idororder_idfilter is required, otherwise the call returns400
buy-label and GET /v1/packages/:id/rates both return 409 order_not_spendable when the package’s order is cancelled, archived, or expired, because a terminal order cannot buy carrier labels. fulfill returns 409 when the package is already fulfilled or an item is missing its serial numbers, and 422 cannot_fulfill_empty_package when the package has no items.
Shippo not configured
If the entity has no Shippo credentials,buy_label: true returns:
buy-label after configuring Shippo.
Webhook delivery: the
fulfillment.shipped event is queued when a package is marked shipped and delivered to any webhook endpoint subscribed to it. See Your First Webhook to register one. To follow a shipment after that, poll GET /v1/tracking-events?package_id=:id.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_qtyacross ALL packages on the order. Over-cap adds return422 package_quantity_exceeds_ordered.
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
422 package_quantity_exceeds_ordered
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.
Automatic packing and pack leftovers
POST /v1/fulfillment/pack-order packs an unpacked or partially packed order into one or more optimally sized packages using the entity’s active box inventory. It allocates inventory and, with auto_rate: true, fetches rates for each package; auto_buy_label: true also buys the labels, and needs auto_rate: true and a configured shipping rule.
fulfillment:write scope plus the fulfillment.pack permission. If the order is already fully packed it returns 409 all_items_already_packed and does nothing. When the order cannot be packed it returns 400 no_active_boxes_configured if the entity has no active box products (add boxes in Products), or 400 no_boxes_for_location if none of the boxes is available at the order’s location (assign a box to that location in Products). Both are returned before the packing service is called. Otherwise it returns 422 with one of these codes:
When Pack with Mighty cannot fit one or more items into any available box, those items are kept as leftovers rather than silently dropped. Leftovers are shown to the warehouse operator on the Pack Order page and the Order Detail > Fulfillment tab, where the operator packs them by hand, dismisses them with a note, backorders them, or splits them to a new order. The public API has no leftovers operations, so list and resolve leftovers in the Arcus app.
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.Response
Inline create options
Restocking fee formats
returns.restocking_fee and return it as a JS number.
RMA lifecycle
POST /v1/returns/:id/receive— record physical receipt of the returned goods without the full inspection and disposition workflow, for simple receive-and-restock flowsPOST /v1/returns/:id/inspect— move the return toinspectingwhen a per-item condition assessment is neededPOST /v1/returns/:id/disposition— record what happened to each item: restock, write off, destroy, send to the vendor, exchange, or quarantinePOST /v1/returns/:id/refund— issue an explicit refund when it was not done inlinePOST /v1/returns/:id/cancel— cancel apending_approval,authorizedorexpectedreturn that has nothing received, and release any inventory holds
Idempotency-Key header and need the returns:write scope.
Receiving and disposition (separate calls)
After the physical goods arrive:receive takes return_item_id, quantity_received and receiving_location_id for every line, and optionally serial_numbers[]. disposition takes return_item_id, disposition (restock, writeoff, destroyed, vendor_return, exchange, or quarantine) and quantity for every line, plus optional final_location_id and condition (new, like_new, good, fair, damaged, or defective). A line whose condition is damaged or defective can be restocked only with force_restock: true and a force_restock_reason.
These errors can come back from receive:
disposition returns the same 409 rma_movement_not_written when its stock movement was already posted.
Refunding a return
POST /v1/returns/:id/refund refunds a received, inspected, restocked, written-off, sent-to-vendor, or closed return. Send an optional amount, a payment_method (original, store_credit, check, or cash), a reason, and optionally allocations[] (each with a payment_id and amount) to split the refund across the original payments. It needs the returns:write and payments:write scopes plus the payments.refund_void permission. Calling it again with the same amount returns the cached result (idempotent: true); a different amount returns 409 refund_already_issued. The other refusals are:
Cancelling a return
POST /v1/returns/:id/cancel takes an optional reason. It needs the returns:write scope plus the returns.cancel permission, and works only while the return is pending_approval, authorized or expected and no unit has been received. It returns 409 cannot_cancel_items_received once any unit is received, and a 409 from any other state; cancelling an already-cancelled return returns 200.
Return-window enforcement
Ifentities.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:Probing a serial before submit
CallPOST /v1/serials/validate-for-return (needs serials:read) to check a serial before submitting the receive. It changes nothing and always answers 200 with a verdict, so check the valid field rather than the status code. The receive and disposition calls enforce the actual gate:
Error codes (hard mode)
Whenserial_validation_mode is hard and a receive/disposition is submitted with a mismatched serial, the endpoint returns 422 with a structured error:
Scopes required
Serial capture during pack
Serialized products require one serial number per unit before a package can be fulfilled. The public API captures serials progressively as units are scanned, withPOST /v1/serials/capture-partial, which needs the serials:write scope.
Capture serials for a package line
Sendpackage_item_id and product_id, plus either raw_barcodes[] or serial_numbers[]. When you send raw_barcodes[], the backend runs barcode parsing (prefix stripping, format normalization, lot extraction) automatically. Optional fields are order_id, package_id, lot_number, expiry_date, notes, and source.
curl
201 and marks each captured serial allocated. Call it again as each additional unit is scanned. The partial flag stays true while remaining is above 0; when remaining reaches 0 and partial is false, the line is ready to fulfill.
Removing a serial
The public API has no operation that releases a captured serial from its package line.DELETE /v1/serials/:id (needs serials:delete) soft-deletes a serial, which can be restored later with POST /v1/serials/:id/restore, and it returns 409 cannot_delete_serial_in_use while the serial is allocated or sold. To change a serial you already captured, release it in the Arcus app. When an order is already fulfilled, use the RMA flow (POST /v1/returns) to return the serialized unit.
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:
buy-label using the package.id from the error body.
