Sync shop orders into deliveries
An end-to-end recipe: book a Maxmove delivery when a shop order is ready, keep the order updated from webhooks, and never book twice.
This recipe connects any shop, ERP, or order system to Maxmove. If you run Shopify, WooCommerce, or Shopware, the ready-made integrations do this for you.
Code examples use BASE_URL and headers from the Quickstart. The PHP examples use Guzzle with the API key set as a default header.
The flow#
Order is ready to ship
Trigger the booking from your own event, for example "paid" or "packed". Optionally quote first if you show the delivery price to a person before booking.
Book the delivery once
Use your order id as
Idempotency-Keyand your order number asexternal_id. A retry after a timeout then returns the same delivery instead of booking a second courier.Store the delivery on the order
Save
id(del_…) andtracking_urlon your order, and send the tracking link to your customer.Update the order from webhooks
Map
data.statusfrom each webhook to your order status, and fetch proof of delivery when it arrives.
Book the delivery#
async function bookDelivery(order: ShopOrder) {
const res = await fetch(`${BASE_URL}/deliveries`, {
method: 'POST',
headers: { ...headers, 'Idempotency-Key': `shop-order-${order.id}` },
body: JSON.stringify({
external_id: order.number,
vehicle_type_id: 'courier',
pickup: {
address: 'Ehrenstraße 15, 50672 Köln',
latitude: 50.9385,
longitude: 6.9469,
contact: { name: 'Feinkost Ehrenfeld', phone: '+49 221 1234567' },
instructions: `Order ${order.number}, packed at the counter.`,
},
dropoff: {
address: order.shippingAddress.formatted,
latitude: order.shippingAddress.latitude,
longitude: order.shippingAddress.longitude,
contact: { name: order.customer.name, phone: order.customer.phone },
instructions: order.deliveryNote ?? undefined,
},
items: order.lines.map((line) => ({
source_line_id: line.id,
description: line.title,
quantity: line.quantity,
weight_kg: line.weightKg,
length_cm: line.lengthCm,
width_cm: line.widthCm,
height_cm: line.heightCm,
sku: line.sku,
})),
}),
});
const body = await res.json();
if (!res.ok) throw new Error(`${body.error.code}: ${body.error.message} (${body.request_id})`);
await saveDeliveryOnOrder(order.id, { deliveryId: body.id, trackingUrl: body.tracking_url });
}use GuzzleHttp\Client;
$client = new Client([
'base_uri' => 'https://api.maxmove.com/v1/',
'headers' => ['x-api-key' => getenv('MAXMOVE_KEY')],
'http_errors' => false,
]);
$response = $client->post('deliveries', [
'headers' => ['Idempotency-Key' => 'shop-order-' . $order['id']],
'json' => [
'external_id' => $order['number'],
'vehicle_type_id' => 'courier',
'pickup' => [
'address' => 'Ehrenstraße 15, 50672 Köln',
'latitude' => 50.9385,
'longitude' => 6.9469,
'contact' => ['name' => 'Feinkost Ehrenfeld', 'phone' => '+49 221 1234567'],
],
'dropoff' => [
'address' => $order['shipping_address'],
'latitude' => $order['shipping_latitude'],
'longitude' => $order['shipping_longitude'],
'contact' => ['name' => $order['customer_name'], 'phone' => $order['customer_phone']],
],
],
]);
$body = json_decode((string) $response->getBody(), true);
if ($response->getStatusCode() >= 400) {
throw new RuntimeException("{$body['error']['code']}: {$body['error']['message']} ({$body['request_id']})");
}
save_delivery_on_order($order['id'], $body['id'], $body['tracking_url']);Your shop must store coordinates with the shipping address. Maxmove does not geocode; use the coordinates your checkout's address autocomplete returns.
Map statuses to your order#
Maxmove data.status | Typical order status |
|---|---|
pending, courier_assigned, at_pickup | Ready for pickup |
picked_up, in_transit, at_dropoff | Out for delivery |
delivered | Delivered |
cancelled | Delivery failed: needs action |
Webhooks can arrive out of order and more than once. Store the event id you processed and only move your order forward.
const RANK = {
pending: 0, courier_assigned: 1, at_pickup: 2, picked_up: 3,
in_transit: 4, at_dropoff: 5, delivered: 6, cancelled: 6,
};
export async function handleMaxmoveEvent(event: MaxmoveEvent) {
if (await alreadyProcessed(event.id)) return;
const order = await findOrderByDeliveryId(event.data.id);
if (order && RANK[event.data.status] >= RANK[order.deliveryStatus]) {
await updateOrderDeliveryStatus(order.id, event.data.status);
}
if (event.type === 'delivery.pod_submitted') await storeProof(event.data.id);
await markProcessed(event.id);
}const RANK = [
'pending' => 0, 'courier_assigned' => 1, 'at_pickup' => 2, 'picked_up' => 3,
'in_transit' => 4, 'at_dropoff' => 5, 'delivered' => 6, 'cancelled' => 6,
];
function handle_maxmove_event(array $event): void {
if (already_processed($event['id'])) return;
$order = find_order_by_delivery_id($event['data']['id']);
if ($order && RANK[$event['data']['status']] >= RANK[$order['delivery_status']]) {
update_order_delivery_status($order['id'], $event['data']['status']);
}
if ($event['type'] === 'delivery.pod_submitted') store_proof($event['data']['id']);
mark_processed($event['id']);
}Verify the signature before you call the handler; see Verify signatures.
Handle the edge cases#
- Customer cancels the order: cancel the delivery while it is
pending,courier_assigned, orat_pickup. See Cancellations. - Delivery ends as
cancelled: flag the order for a person. Booking again needs a newIdempotency-Key, for exampleshop-order-1001-2, and a newexternal_idsuch as1001-2, becauseexternal_idis unique per mode (409 duplicate_external_id). - Address outside the service area: the create request fails with
400 service_area_unavailable. Offer another shipping method at checkout. - Too much for the vehicle:
400 vehicle_does_not_fit. Retry with a larger vehicle type; see Items and vehicle fit.
Did this answer your question?