Back to Arky

Shipping and Fulfillment

Shipping profiles and methods with their rates, routing, fulfillment jobs, and deliveries and pickups

Getting goods to people uses five records:

  • a shipping profile is a shipping label for products that ship the same way, such as “standard” or “fragile”;
  • a shipping method is the customer’s choice at checkout (a delivery, or a pickup at one location) with its rates inside it;
  • fulfillment routing is the store’s rules for which location fulfills a delivery;
  • a fulfillment job is one location’s work on all or part of one order delivery, or one rental replacement;
  • a fulfillment is a set of units that leave together: a delivery that is dispatched and delivered, or a pickup that is collected.

Shipping Profiles

POST /v1/stores/{store_id}/shipping-profiles
SDK: sdk.store.shippingProfile.create()
GET /v1/stores/{store_id}/shipping-profiles
SDK: sdk.store.shippingProfile.find()
PUT /v1/stores/{store_id}/shipping-profiles/{id}
SDK: sdk.store.shippingProfile.update()
DELETE /v1/stores/{store_id}/shipping-profiles/{id}?expected_updated_at=
SDK: sdk.store.shippingProfile.delete()

A profile is { id, store_id, key, created_at, updated_at }. Every physical variant names one. Items of different profiles go in separate deliveries, each with its own method and rate.

Shipping Methods And Rates

POST /v1/stores/{store_id}/shipping-methods
SDK: sdk.store.shippingMethod.create()
GET /v1/stores/{store_id}/shipping-methods
SDK: sdk.store.shippingMethod.find()
PUT /v1/stores/{store_id}/shipping-methods/{id}
SDK: sdk.store.shippingMethod.update()
DELETE /v1/stores/{store_id}/shipping-methods/{id}?expected_updated_at=
SDK: sdk.store.shippingMethod.delete()
const courier = await sdk.store.shippingMethod.create({
	store_id: storeId,
	id: crypto.randomUUID(),
	key: "courier",
	blocks: [{ id: crypto.randomUUID(), key: "name", type: "localized_text", value: { en: "Courier", de: "Kurier" } }],
	type: { type: "delivery" },
	tax: { type: "category", tax_category_id: standardTax.id },
	rates: [
		{
			id: crypto.randomUUID(),
			zone_id: domestic.id,
			shipping_profile_id: standardProfile.id,
			conditions: [],
			pricing: { type: "flat", amount: 1_000, free_above_subtotal: 10_000 },
			delivery_estimate: { min_business_days: 1, max_business_days: 3 },
			starts_at: null,
			ends_at: null,
		},
	],
	status: { type: "active" },
});
  • A method is a delivery, or a pickup at one store location. A location offers pickup exactly when an active pickup method names it.
  • Rates live inside the method and are written with it, up to 500. Each rate names a zone and a profile, optional conditions (sales channels, customer groups or a whole customer group offering, companies, company locations, subtotal or weight bounds), a flat or weight-tiered price with an optional free-above subtotal, and an optional delivery estimate.
  • Two rates that could match the same cart are refused when saved: same zone and profile, overlapping dates, and conditions that could all hold together.
  • Fixed amounts are in the currency of the rate’s zone’s market. Subtotal bounds use the delivery’s merchandise after item and order discounts, before delivery discounts and without separately added tax.
  • A unit’s weight is its kit items’ weights times their quantities. A rate that uses weight, through a condition or tiers, needs every unit’s weight: with an unknown weight that rate is left out of the quote’s offers and listed as unavailable with SHIPPING.WEIGHT_UNKNOWN, never counted as zero.
  • tax is a tax category, or follow_goods: the delivery price is split across the delivery’s goods in proportion to their net amounts, and each part is taxed like those goods.
  • archived takes a method off checkout and keeps it for history.

The cart picks a method with selectShippingMethod; the quote shows each delivery’s offers.

Fulfillment Routing

GET /v1/stores/{store_id}/fulfillment-routing
SDK: sdk.eshop.fulfillmentRouting.get()
PUT /v1/stores/{store_id}/fulfillment-routing
SDK: sdk.eshop.fulfillmentRouting.update()
const routing = await sdk.eshop.fulfillmentRouting.get({ store_id: storeId });

await sdk.eshop.fulfillmentRouting.update({
	store_id: storeId,
	expected_updated_at: routing.updated_at,
	rules: [
		{
			id: crypto.randomUUID(),
			key: "croatia-from-zagreb",
			conditions: [{ type: "zones", zone_ids: [croatia.id] }],
			target: { location_ids: [zagreb.id], assign: { type: "automatic", pick: { type: "in_list_order" }, split: { type: "never" } } },
			status: { type: "active" },
		},
	],
	otherwise: { location_ids: [sarajevo.id], assign: { type: "manual" } },
});
  • A pickup is prepared at its pickup location, and a company location served from a store location uses that location while it can deliver. Otherwise the first active rule whose conditions all hold and whose target can deliver decides, and otherwise catches the rest.
  • Rules match markets, sales channels, zones and shipping profiles; there are at most fifty. A location can deliver when it is active, has a shipping address and has a stock level for every tracked item of the delivery.
  • A target assigns automatically (picking locations in list order or by most stock, and splitting a delivery when needed or never) or leaves the choice to staff (manual), holding the job until they confirm or move it. Units no location covers wait at the first location, held out_of_stock.
  • Editing replaces the rules and otherwise together and needs Store Admin.

Fulfillment Jobs

GET /v1/stores/{store_id}/fulfillment-jobs
SDK: sdk.eshop.fulfillmentJob.find()
GET /v1/stores/{store_id}/fulfillment-jobs/{id}
SDK: sdk.eshop.fulfillmentJob.get()
POST /v1/stores/{store_id}/fulfillment-jobs/{id}/decisions
SDK: sdk.eshop.fulfillmentJob.decide()
GET /v1/stores/{store_id}/fulfillment-jobs/{id}/items
SDK: sdk.eshop.fulfillmentJob.findItems()
GET /v1/stores/{store_id}/fulfillment-jobs/{id}/cash-collections
SDK: sdk.eshop.fulfillmentJob.getPickupCash()
POST /v1/stores/{store_id}/fulfillment-jobs/{id}/cash-collections
SDK: sdk.eshop.fulfillmentJob.recordPickupCash()

A job is one location’s share of one order delivery, or one rental replacement. Its method is a delivery to an address from a location, with the source that chose the location (served_from, a routing rule, otherwise or a staff move), or a pickup at a location. Its contact is read from the order when the job is read, so a corrected contact shows at once.

  • Its status is computed when read: scheduled before its delivery window starts, on_hold while it has a hold, open, in_progress once something left, then completed or cancelled.
  • A scheduled job reserves no stock; its location reserves when the window opens, and a shortfall then holds the job out_of_stock.
  • Holds stop goods leaving and are released one by one: awaiting_payment (card and manual orders, until the order owes nothing), awaiting_location_choice, out_of_stock (lifts when stock arrives), manual with a note, and handed_back with a note.
  • items shows a packer each line’s product key, SKU, current blocks and main picture, without prices, so packing needs no catalog permission.
const job = await sdk.eshop.fulfillmentJob.get({ store_id: storeId, fulfillment_job_id: jobId });

await sdk.eshop.fulfillmentJob.decide({
	store_id: storeId,
	fulfillment_job_id: job.id,
	expected_updated_at: job.updated_at,
	action: { type: "move", to_store_location_id: zagreb.id },
});

Staff act on a job with one action and the expected_updated_at they read: move it to another location routing names, split units that haven’t left into a new job there (with the id the app picks), hand_back with a note, hold with a note, or release_hold naming the hold. Moving and splitting need fulfillment everywhere and happen before anything has left; pickup jobs never move. Staff limited to some locations work their jobs there and hand them back. A repeated action finds the job changed and is refused.

A clerk with fulfillment reach at a pickup location records the cash of a cash-on-delivery pickup with recordPickupCash, carrying the job’s and the payment’s versions; the pickup is collected only once its cash is recorded. getPickupCash gives that clerk what to record without reading the order or its payments: { payment_id, payment_updated_at, due }, or null when the job is not a pickup of an order with an open cash-on-delivery payment, or the order owes nothing.

Fulfillments

POST /v1/stores/{store_id}/fulfillments
SDK: sdk.eshop.fulfillment.create()
GET /v1/stores/{store_id}/fulfillments
SDK: sdk.eshop.fulfillment.find()
POST /v1/stores/{store_id}/fulfillments/{id}/commands
SDK: sdk.eshop.fulfillment.execute()
POST /v1/stores/{store_id}/fulfillments/{id}/tracking
SDK: sdk.eshop.fulfillment.updateTracking()
POST /v1/stores/{store_id}/fulfillments/{id}/delivered
SDK: sdk.eshop.fulfillment.markDelivered()
const parcel = await sdk.eshop.fulfillment.create({
	store_id: storeId,
	id: crypto.randomUUID(),
	fulfillment_job_id: job.id,
	lines: [{ fulfillment_job_line_id: lineId, unit_spans: [{ first_unit: 0, quantity: 2 }], selected_units: [], lot_reference: null }],
});

await sdk.eshop.fulfillment.execute({
	store_id: storeId,
	fulfillment_id: parcel.id,
	expected_updated_at: parcel.updated_at,
	action: { type: "fulfill", timing: { type: "on_time" } },
	notify_customer: true,
	trackings: [{ carrier: "Post", number: "RR123456789DE", url: null }],
	lot_references: [],
});
  • A delivery is preparing, then dispatched (with who sent it and whether it was on time), then delivered, and carries up to 20 trackings, typed when it leaves or later. A pickup is preparing, then ready, then collected. Either can be cancelled before the goods leave; goods that left come back through a return.
  • Goods leaving at or after the end of the delivery window need a late timing with a reason.
  • The fulfillment’s id is picked by the app, so a retried create returns the same fulfillment. Each line names a line of its job; a line the job doesn’t have is refused with 409 FULFILLMENT.OWNER_MISMATCH and nothing is saved.
  • markDelivered sets delivered_at, which can be corrected but never to before the dispatch.
  • A ready pickup sends the order_ready_for_pickup email, a dispatch order_dispatched, and the first delivered order_delivered, to the order’s contact in the order’s language, unless the action’s notify_customer is false. A rental replacement has no order and sends none.
  • Webhooks: fulfillment.created, fulfillment.ready, fulfillment.dispatched, fulfillment.delivered, fulfillment.collected, fulfillment.tracking_updated and fulfillment.cancelled.

Customer Delivery Timeline

const timeline = await arky.eshop.order.findFulfillments({ order_id: order.id });

The storefront reads each job of its order with its method, status (scheduled, pending, in_progress, completed or cancelled), delivery timing and shipments, each with its own status and times, without staff details.

Note

Returns and rentals have their own records on the E-shop page.