# 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](https://maxmove.com/en/developers/docs/integrations) do this for you.

Code examples use `BASE_URL` and `headers` from the [Quickstart](https://maxmove.com/en/developers/docs/quickstart). The PHP examples use Guzzle with the API key set as a default header.

## The flow

**1. 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.

**2. Book the delivery once**

Use your order id as `Idempotency-Key` and your order number as `external_id`. A retry after a timeout then returns the same delivery instead of booking a second courier.

**3. Store the delivery on the order**

Save `id` (`del_…`) and `tracking_url` on your order, and send the tracking link to your customer.

**4. Update the order from webhooks**

Map `data.status` from each [webhook](https://maxmove.com/en/developers/docs/webhooks/events) to your order status, and fetch [proof of delivery](https://maxmove.com/en/developers/docs/guides/proof-of-delivery) when it arrives.

## Book the delivery

```ts Node.js
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 });
}
```

```php PHP
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.

```ts Node.js
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);
}
```

```php PHP
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](https://maxmove.com/en/developers/docs/webhooks/verify-signatures).

## Handle the edge cases

- **Customer cancels the order:** cancel the delivery while it is `pending`, `courier_assigned`, or `at_pickup`. See [Cancellations](https://maxmove.com/en/developers/docs/guides/cancellations).
- **Delivery ends as `cancelled`:** flag the order for a person. Booking again needs a new `Idempotency-Key`, for example `shop-order-1001-2`, and a new `external_id` such as `1001-2`, because `external_id` is 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](https://maxmove.com/en/developers/docs/guides/items-and-vehicle-fit).

---

Source: https://maxmove.com/en/developers/docs/guides/shop-order-sync
