Shipping and delivery
Shipping in ARC means booking a courier, called a delivery partner, to carry goods out to a customer or to collect a customer return. A booking creates a Shipment. You then follow it to delivery and record what you paid the courier.
A shipment has a direction: OUTBOUND for an order's delivery, RETURN for a pickup from the customer. Both appear in the same list and use the same operations.
Connecting couriers, changing delivery settings, booking, cancelling and updating a shipment need the delivery partners feature on your plan. Plain reads of shipments, settings and configuration do not. Each operation also needs the permission shown in its section. For the exact field list of every type, see the API reference. The examples use <...> for values you supply.
1. Plan the integration
| Job | Operations | Permission |
|---|---|---|
| Connect a courier | deliveryPartnerProviders, deliveryPartnerConfigs, upsertDeliveryPartnerConfig, testDeliveryPartnerConnection, registerDeliveryPartnerWebhook, setDeliveryPartnerEnabled, reorderDeliveryPartners, deleteDeliveryPartnerConfig | settings:integration:read, settings:integration:update, settings:integration:delete |
| Configure delivery | deliverySettings, deliveryPackingSettings, deliveryBoxPresets, deliveryCustomerDisplay, deliverySeenServices and their update... mutations | settings:sales:read, settings:sales:update |
| Product and category defaults | productDeliveryFlags, categoryDeliveryDefaults, productVariantsMissingShippingData and the update... mutations | products:product:read, products:product:update, categories:category:read, categories:category:update |
| Quote and book | staffDeliveryOptions, bookableDeliveryPartners, shipmentRates, packingRatePreview, bookShipmentForShippedGoods, bookReturnPickup, cancelShipment | sales:sales_order:create, sales:sales_order:ship, sales:shipment:create, sales:shipment:cancel |
| Follow and close out | shipments, shipment, trackShipment, refreshShipmentTracking, recordShipmentStatus, markShipmentCourierPaid and the rest of section 6 | sales:shipment:list, sales:shipment:read, sales:shipment:update, sales:sales_order:ship |
A user who is limited to their own customers sees only the shipments whose order or return they can open. An out-of-scope shipment is returned as not found. The cost columns on shipments are filled only for callers with sales:sales_order:ship.
2. Connect a courier
Do this once per courier, in this order. A courier can carry shipments only after it has passed a connection test and been switched on.
List what you can connect
Requires settings:integration:read.
query Providers {
deliveryPartnerProviders {
key
displayName
kind
capabilities
supportedCountries
supportedCurrencies
webhookAuth
}
}
Read the credential and settings fields a provider declares, and build your form from them. deliveryPartnerConfigs lists the couriers already set up. It never returns a secret. It lists which secret keys are stored in configuredSecretKeys.
Save, test, register, enable
upsertDeliveryPartnerConfigcreates or updates a configuration. Requiressettings:integration:update. Secret fields are write-only and stored encrypted, and an empty value keeps the stored one. Setting up a provider that is already set up is refused. Changing credentials takes an enabled partner offline until you verify it again.
mutation SaveCourier($input: UpsertDeliveryPartnerConfigInput!) {
upsertDeliveryPartnerConfig(input: $input) {
id
isEnabled
verification {
keysValid
needsReverification
webhookReachable
}
}
}
{
"input": {
"providerKey": "<provider key from deliveryPartnerProviders>",
"environment": "<environment>",
"displayName": "<display name>",
"credentials": [{ "key": "<credential field>", "value": "<secret>" }],
"settings": [{ "key": "<settings field>", "value": "<value>" }],
"useForCheckout": true
}
}
testDeliveryPartnerConnection(id)makes a real call to the courier, records the result and returns the courier's exact rejection when it fails. A partner must pass this before it can be enabled. A manual courier is verified when you save it.registerDeliveryPartnerWebhook(id)registers your organization's webhook URL through the courier's own API. A provider without such an API is refused, with a note that its webhook is set up in the courier's own dashboard.setDeliveryPartnerEnabled(id, enabled: true)turns the courier on. It needs a verified connection and, for a courier that can only report status by webhook, a webhook that has already arrived.
reorderDeliveryPartners(ids) sets the order, top first. The order drives the partner list, the automatic partner-order rule and every tie-break. Send at most 200 ids, with none repeated. deleteDeliveryPartnerConfig(id) (requires settings:integration:delete) switches the partner off and stops its webhook URL for good. You can set the same provider up again later.
3. Configure delivery
These settings shape what customers are offered and how boxes are packed. They take effect at the next quote.
deliverySettingsandupdateDeliverySettingshold how the delivery fee is charged (feeMode:FLATorLIVE_QUOTE), the flat fee, the free-delivery thresholds, the default pickup warehouse and the customer delivery emails.deliveryPackingSettings,updateDeliveryPackingSettingsanddeliveryBoxPresetshold the boxes used for packing, in centimetres and kilograms.deliveryCustomerDisplayandupdateDeliveryCustomerDisplayhold how options are shown to customers. Omitted fields keep their saved value.deliveryCustomerDisplayPreviewshows, without saving, what customers and staff would see for one address. It calls the couriers.deliverySeenServiceslists the courier services each partner has returned, with when each was last seen and whether customers are offered it.
Reads need settings:sales:read. Changes need settings:sales:update.
Boxes are packed from each variant's own weight and size. Find the variants that cannot be packed yet:
query MissingData($take: Int) {
productVariantsMissingShippingDataCount
productVariantsMissingShippingData(take: $take) {
total
skip
take
}
}
Per product, updateProductDeliveryFlags sets whether it ships in its own packaging and whether it is fragile. A flag you leave out keeps its value, and null clears it. Per category, updateCategoryDeliveryDefaults replaces the category's defaults and keeps its other metadata. categoryDeliveryDefaults returns both the category's own defaults and what applies to the items in it.
4. Quote and book a shipment
Before the order exists
staffDeliveryOptions returns live options and prices for a draft sales order. Requires sales:sales_order:create. The customer must be in the caller's own book. Pass the chosen option's ID to createSalesOrder as its delivery option. Without the delivery partners feature it answers that none are available, and the manual shipping amount stands. When an order packs above the store's freight threshold, no courier is asked and the answer carries the code DELIVERY_FREIGHT_QUOTE. You may place the order without an option, and staff quote the delivery charge before invoicing.
Price a shipment
shipmentRates prices a shipment with every enabled partner, at your cost. Requires sales:sales_order:ship. Give exactly one of salesOrderId or productReturnId. A return pickup is offered only by partners that handle returns, and lines apply to a sales order, not a return.
query Rates($input: ShipmentRatesInput!) {
shipmentRates(input: $input) {
options {
deliveryPartnerConfigId
partnerDisplayName
serviceLevelCode
serviceLevelName
amount
currency
providerQuoteRef
expiresAt
}
unavailable { partnerDisplayName reason retryable }
suggestedParcels { name lengthCm widthCm heightCm weightKg }
}
}
Partners that could not quote are listed in unavailable with the reason, so one failing courier never hides the others. packingRatePreview packs given lines and prices both packings with every enabled partner. It uses the same rule as checkout and stores nothing. bookableDeliveryPartners lists the enabled partners staff can book with, without secrets, for a ship dialog or pickup picker.
Book
Both booking mutations need sales:shipment:create.
bookShipmentForShippedGoods(input)books a courier for goods that have already shipped from a sales order. It never issues stock. When every shipped item is already covered by a shipment, it is refused and asks you to ship the remaining items first.bookReturnPickup(delivery, productReturnId)books a courier to collect a customer return, using a partner set up for returns. The booking is all-or-nothing and shows the courier's own error if it fails.
mutation Book($input: BookShipmentForShippedGoodsInput!) {
bookShipmentForShippedGoods(input: $input) {
id
status
carrierName
trackingNumber
errorMessage
}
}
{
"input": {
"salesOrderId": "<sales-order-id>",
"delivery": {
"deliveryPartnerConfigId": "<partner config id from the rate option>",
"serviceLevelCode": "<service level code from the rate option>",
"providerQuoteRef": "<providerQuoteRef from the rate option>",
"quotedAmount": "<amount from the rate option>",
"quotedCurrency": "<currency from the rate option>"
}
}
}
Copy the quote fields from the rate option you chose so the booking matches what you were shown. shipmentCustomerPhone returns the number a booking falls back to when you give no recipient phone: the shipping address phone, then the order's mobile, then the customer's contact. Give exactly one of salesOrderId or productReturnId.
Cancel
cancelShipment(id, reason) cancels with the courier. Requires sales:shipment:cancel. Stock is not returned: a return does that. A shipment the courier already holds (picked up, in transit or out for delivery), or one that has finished, cannot be cancelled. That is checked before the courier is contacted.
5. Track a shipment
List and open shipments with shipments, shipment, salesOrderShipments and productReturnShipments. Requires sales:shipment:list for the tenant-wide list and sales:shipment:read for the rest. shipments is newest first and takes skip and take (25 by default, at most 100) and a filter by status group, direction, search, created window and courier payment.
Shipment statuses are DRAFT, SUBMITTED, BOOKING, BOOKED, BOOKING_FAILED, BOOKING_ORPHANED, AWAITING_PICKUP, PICKED_UP, IN_TRANSIT, OUT_FOR_DELIVERY, DELIVERED, FAILED_ATTEMPT, EXCEPTION, RETURNED_TO_SENDER, LOST and CANCELLED. The list tabs group them as PENDING, BOOKED, IN_TRANSIT, OUT_FOR_DELIVERY, DELIVERED, PROBLEM and CANCELLED. shipmentStatusGroupCounts and shipmentsSummary return the counts under a filter.
query Track($shipmentId: ID!) {
trackShipment(shipmentId: $shipmentId) {
checkedAt
carrierError
shipment {
id
status
trackingNumber
trackingUrl
}
}
}
trackShipmentreturns the shipment, its progress steps and when the courier was last asked. Opening it asks the courier, and the answer is reused for 60 seconds.refresh: trueasks again unless the last ask was under 10 seconds ago. A courier failure setscarrierErrorto the courier's own words and still returns the saved timeline.refreshShipmentTracking(id)asks for the latest status now and updates the shipment. Requiressales:shipment:update.shipmentLiveLocation(shipmentId)returns the courier's live position, cached for 15 seconds per shipment. It is null unless the courier supports live location and is carrying the shipment (picked up or out for delivery).shipmentLabelUrl,shipmentProofOfDeliveryUrl,shipmentPackingListUrl,shipmentStickerUrlandshipmentsStickersUrl(up to 100 ids) return short-lived signed download links. Fetch the link when you need the file, not ahead of time.
For a custom courier that reports no status itself, record it by hand with recordShipmentStatus(id, input). Requires sales:shipment:update. A courier that reports its own status is refused: use refreshShipmentTracking. Only the statuses staff may set are accepted, and the move must be allowed from the current status. Marking an order's outbound shipment DELIVERED also needs the sales order deliver permission, because it can mark the order delivered.
A booking that needs checking
A shipment in BOOKING_ORPHANED is a booking whose outcome is unknown. Check with the courier, then call resolveOrphanedShipment(id, input). Requires sales:shipment:update. CONFIRM_BOOKED records that the courier holds the booking, optionally with its tracking number. MARK_CANCELLED records that you cancelled it there. It never calls the courier, and a shipment in any other status is refused.
6. Record what you paid the courier
These operations record payment only. No money moves. All need sales:sales_order:ship.
markShipmentCourierPaid(shipmentId, input)records date (YYYY-MM-DD), method and reference. It is refused for a cancelled shipment or a failed booking. Marking again overwrites the record, and every change is audited.markShipmentsCourierPaid(input)records one payment on 1 to 500 shipments. It is all or nothing: it is refused when any shipment is not found, is cancelled or failed, or is already marked paid. It returns the courier charges settled per currency.clearShipmentCourierPayment(shipmentId)removes a payment recorded by mistake.
7. Common errors
| What you see | Why | What to do |
|---|---|---|
| A booking, cancel or settings change is refused for your plan | The delivery partners feature is not on your plan | Enable the feature, or use the manual shipping amount |
| Enabling a partner is refused | The connection is not verified, or a webhook-only courier has not sent a webhook yet | Run testDeliveryPartnerConnection, then registerDeliveryPartnerWebhook, then enable |
| Saving a configuration is refused | The provider is already set up | Update the existing configuration by its id |
| A courier is missing from the rate options | It could not quote | Read its entry in unavailable. retryable says whether to try again |
bookShipmentForShippedGoods asks you to ship the remaining items | Every shipped item already has a shipment | Ship the remaining items first |
cancelShipment is refused | The courier already holds the parcel, or the shipment has finished | Wait for delivery, then arrange a return |
recordShipmentStatus is refused | The courier reports its own status, or the move is not allowed from the current status | Use refreshShipmentTracking, or pick an allowed status |
trackShipment returns carrierError | The courier call failed | Show the saved timeline and the courier's message, and retry later |
markShipmentsCourierPaid is refused | One shipment is missing, cancelled, failed or already paid | Remove it from the list and retry |
Booking and rating failures carry the courier's own words, so show them to the user instead of replacing them with a generic message.