# Maxmove developer documentation > Full text of https://maxmove.com/en/developers/docs, generated from the same source as the website. API reference: https://api.maxmove.com/v1/docs. ## Instructions for AI tools working with the Maxmove API - Use the `/v1` API at `https://api.maxmove.com/v1`. The OpenAPI document at https://api.maxmove.com/v1/openapi.json is the source of truth for fields and responses. - Authenticate with the `x-api-key` header (or `Authorization: Bearer`) and keep keys on the server. Start with a test key (`mm_test_…`): test deliveries are simulated and never billed ([authentication](https://maxmove.com/en/developers/docs/authentication.md), [test mode](https://maxmove.com/en/developers/docs/test-mode.md)). - Send an `Idempotency-Key` with every `POST /v1/deliveries` and reuse it when retrying the same request ([idempotency](https://maxmove.com/en/developers/docs/concepts/idempotency.md)). - Quote first with `POST /v1/quotes` and pass the `quote_id`; the delivery's route and `scheduled_at` must match the quote or the API answers `400 quote_mismatch` ([quotes](https://maxmove.com/en/developers/docs/concepts/quotes.md)). - Prefer webhooks over polling, verify every signature, deduplicate by event id, and read `data.status` instead of relying on event order ([webhook events](https://maxmove.com/en/developers/docs/webhooks/events.md), [verify signatures](https://maxmove.com/en/developers/docs/webhooks/verify-signatures.md)). - Branch on `error.code`, not on messages, and back off on `429` ([errors](https://maxmove.com/en/developers/docs/concepts/errors.md), [rate limits](https://maxmove.com/en/developers/docs/concepts/rate-limits.md)). --- # Maxmove API > Book and track same-day courier deliveries from your own software, receive signed webhooks, and push fleet orders onto your dispatch board. The Maxmove API lets your software book deliveries without the dashboard: price a route, book a courier, follow the delivery, and get notified about every change. Fleets can also push their customers' orders straight onto their own dispatch board. All requests go to one base URL. Live and test keys use the same base URL. ```text https://api.maxmove.com/v1 ``` - [Quickstart](https://maxmove.com/en/developers/docs/quickstart): Create a test delivery in four requests. - [Authentication](https://maxmove.com/en/developers/docs/authentication): Create API keys and choose their permissions. - [Webhooks](https://maxmove.com/en/developers/docs/webhooks/events): Receive a signed event for every delivery change. - [API reference](https://maxmove.com/en/developers/docs/api-reference): Every endpoint, field, and response, with a request playground. ## What you can build - **Deliveries from your shop, ERP, or TMS.** [Quote a route](https://maxmove.com/en/developers/docs/concepts/quotes), [create the delivery](https://maxmove.com/en/developers/docs/concepts/deliveries), and track it until it is delivered. - **Status sync.** Subscribe to [webhook events](https://maxmove.com/en/developers/docs/webhooks/events) instead of polling. - **Order intake for fleets.** Let your customers' systems push orders onto your dispatch board with the [fleet order API](https://maxmove.com/en/developers/docs/fleet/order-ingestion). ## How a delivery works **1. Quote** `POST /v1/quotes` prices a route for one vehicle type. The quote is valid for about 5 minutes. **2. Create** `POST /v1/deliveries` books a courier for the same route, with a contact for pickup and dropoff. **3. Track** `GET /v1/deliveries/{deliveryId}/tracking` returns the status, ETA, and courier position. Webhooks report every change. **4. Cancel if needed** `POST /v1/deliveries/{deliveryId}/cancel` works until the courier has picked up the goods. ## Not a developer? If you sell through Shopify, Shopware, or WooCommerce, or want to book in ChatGPT or Claude, you don't need to write code. See [Integrations](https://maxmove.com/en/developers/docs/integrations) for the ready-made apps, and the [Maxmove help center](https://maxmove.com/en/help) for everything about using Maxmove. --- Source: https://maxmove.com/en/developers/docs --- # Quickstart > Create your first test delivery: pick a vehicle type, quote a route, book it, and follow the simulated courier. This guide books a delivery from Köln to Düsseldorf with a test key. Test deliveries are never dispatched or billed. A simulated courier, the Robocourier, moves them through every status. ## Before you start You need a business or fleet workspace in Maxmove in which you are an owner or admin. Only owners and admins can create API keys. ## Create a test delivery **1. Create a test key** In the [dashboard](https://dashboard.maxmove.com), open **Settings → API keys** and select **Create API key**. Choose **Test**, keep the permissions you need, and copy the key. It starts with `mm_test_` and is shown only once. Store it in an environment variable: ```bash export MAXMOVE_KEY="mm_test_…" ``` See [Authentication](https://maxmove.com/en/developers/docs/authentication) for permissions and expiration. **2. Pick a vehicle type** List the vehicle types you can book. Pass an `id` from the response as `vehicle_type_id` in the next steps. The examples use `courier`. ```bash cURL curl https://api.maxmove.com/v1/vehicle-types \ -H "x-api-key: $MAXMOVE_KEY" ``` ```ts Node.js const BASE_URL = 'https://api.maxmove.com/v1'; const headers = { 'x-api-key': process.env.MAXMOVE_KEY!, 'content-type': 'application/json', }; const res = await fetch(`${BASE_URL}/vehicle-types`, { headers }); const { vehicle_types } = await res.json(); console.log(vehicle_types.map((type: { id: string }) => type.id)); ``` ```python Python import os import requests BASE_URL = "https://api.maxmove.com/v1" headers = {"x-api-key": os.environ["MAXMOVE_KEY"]} res = requests.get(f"{BASE_URL}/vehicle-types", headers=headers) print([vehicle_type["id"] for vehicle_type in res.json()["vehicle_types"]]) ``` **3. Price the route** Send pickup and dropoff with their coordinates. Maxmove does not geocode, so send the coordinates your address search returned. The quote is valid for about 5 minutes. ```bash cURL curl -X POST https://api.maxmove.com/v1/quotes \ -H "x-api-key: $MAXMOVE_KEY" -H "content-type: application/json" \ -d '{ "pickup": { "address": "Ehrenstraße 15, 50672 Köln", "latitude": 50.9385, "longitude": 6.9469 }, "dropoff": { "address": "Königsallee 27, 40212 Düsseldorf", "latitude": 51.2233, "longitude": 6.7768 }, "vehicle_type_id": "courier" }' ``` ```ts Node.js const pickup = { address: 'Ehrenstraße 15, 50672 Köln', latitude: 50.9385, longitude: 6.9469 }; const dropoff = { address: 'Königsallee 27, 40212 Düsseldorf', latitude: 51.2233, longitude: 6.7768 }; const quoteRes = await fetch(`${BASE_URL}/quotes`, { method: 'POST', headers, body: JSON.stringify({ pickup, dropoff, vehicle_type_id: 'courier' }), }); const quote = await quoteRes.json(); console.log(quote.id, quote.amount_cents, quote.currency, quote.valid_until); ``` ```python Python pickup = {"address": "Ehrenstraße 15, 50672 Köln", "latitude": 50.9385, "longitude": 6.9469} dropoff = {"address": "Königsallee 27, 40212 Düsseldorf", "latitude": 51.2233, "longitude": 6.7768} quote = requests.post( f"{BASE_URL}/quotes", headers=headers, json={"pickup": pickup, "dropoff": dropoff, "vehicle_type_id": "courier"}, ).json() print(quote["id"], quote["amount_cents"], quote["currency"], quote["valid_until"]) ``` The response contains the quote `id` (`qt_…`), the price in `amount_cents` and `currency`, and `valid_until`. **4. Book the delivery** Send the same route, the `quote_id`, and who the courier meets at each end. The `Idempotency-Key` header is required: if the request times out, retry it with the same key and body, and you get the same delivery instead of a second one. ```bash cURL curl -X POST https://api.maxmove.com/v1/deliveries \ -H "x-api-key: $MAXMOVE_KEY" -H "content-type: application/json" \ -H "Idempotency-Key: order-4711" \ -d '{ "quote_id": "qt_…", "pickup": { "address": "Ehrenstraße 15, 50672 Köln", "latitude": 50.9385, "longitude": 6.9469, "contact": { "name": "Feinkost Ehrenfeld", "phone": "+49 221 1234567" } }, "dropoff": { "address": "Königsallee 27, 40212 Düsseldorf", "latitude": 51.2233, "longitude": 6.7768, "contact": { "name": "Erika Muster", "phone": "+49 170 1234567" }, "instructions": "Ring at reception, 2nd floor." }, "vehicle_type_id": "courier", "external_id": "order-4711" }' ``` ```ts Node.js const deliveryRes = await fetch(`${BASE_URL}/deliveries`, { method: 'POST', headers: { ...headers, 'Idempotency-Key': 'order-4711' }, body: JSON.stringify({ quote_id: quote.id, pickup: { ...pickup, contact: { name: 'Feinkost Ehrenfeld', phone: '+49 221 1234567' }, }, dropoff: { ...dropoff, contact: { name: 'Erika Muster', phone: '+49 170 1234567' }, instructions: 'Ring at reception, 2nd floor.', }, vehicle_type_id: 'courier', external_id: 'order-4711', }), }); const delivery = await deliveryRes.json(); console.log(deliveryRes.status, delivery.id, delivery.status); ``` ```python Python delivery = requests.post( f"{BASE_URL}/deliveries", headers={**headers, "Idempotency-Key": "order-4711"}, json={ "quote_id": quote["id"], "pickup": { **pickup, "contact": {"name": "Feinkost Ehrenfeld", "phone": "+49 221 1234567"}, }, "dropoff": { **dropoff, "contact": {"name": "Erika Muster", "phone": "+49 170 1234567"}, "instructions": "Ring at reception, 2nd floor.", }, "vehicle_type_id": "courier", "external_id": "order-4711", }, ).json() print(delivery["id"], delivery["status"]) ``` A new delivery answers `201` with status `pending`. If the route differs from the quoted route, the request fails with `400 quote_mismatch`; if the quote has expired, with `400 quote_expired`. Request a new quote and retry. **5. Follow the delivery** Read the tracking snapshot. The Robocourier advances the delivery one status about every 20 seconds until it is `delivered`. ```bash cURL curl https://api.maxmove.com/v1/deliveries/del_…/tracking \ -H "x-api-key: $MAXMOVE_KEY" ``` ```ts Node.js const trackingRes = await fetch(`${BASE_URL}/deliveries/${delivery.id}/tracking`, { headers }); const tracking = await trackingRes.json(); console.log(tracking.status, tracking.eta_seconds, tracking.tracking_url); ``` ```python Python tracking = requests.get( f"{BASE_URL}/deliveries/{delivery['id']}/tracking", headers=headers ).json() print(tracking["status"], tracking["eta_seconds"], tracking["tracking_url"]) ``` Instead of polling, [register a webhook endpoint](https://maxmove.com/en/developers/docs/webhooks/events) to get an event for every change. ## Go live When your integration works in test mode, create a live key (`mm_live_…`) and swap it in. The base URL and requests stay the same. Live deliveries are dispatched to real couriers and billed to your workspace on its monthly invoice, so your workspace must be approved for invoice billing first. See [Billing](https://maxmove.com/en/developers/docs/billing). Webhook endpoints belong to one mode: register your endpoints again with the live key. ## Next steps - [Delivery lifecycle](https://maxmove.com/en/developers/docs/concepts/deliveries): Statuses, cancellation, tracking, and proof of delivery. - [Webhooks](https://maxmove.com/en/developers/docs/webhooks/events): Get events instead of polling. - [Test mode](https://maxmove.com/en/developers/docs/test-mode): Simulate cancellations and failed deliveries. - [Errors](https://maxmove.com/en/developers/docs/concepts/errors): Error codes and how to handle them. --- Source: https://maxmove.com/en/developers/docs/quickstart --- # Authentication and API keys > Create live and test API keys in the Maxmove dashboard, limit them to the permissions they need, and send them with every request. Every request authenticates with an API key of one Maxmove workspace. Keys are created in the dashboard and come in two modes: | Key | Mode | What it does | | --- | --- | --- | | `mm_live_…` | Live | Creates real deliveries that are dispatched to couriers and billed to your workspace. | | `mm_test_…` | Test | Creates simulated deliveries that are never dispatched or billed. See [Test mode](https://maxmove.com/en/developers/docs/test-mode). | ## Create a key **1. Open the API keys settings** In the [Maxmove dashboard](https://dashboard.maxmove.com), go to **Settings → API keys**. The section exists in the web dashboard for business and fleet workspaces. Only owners and admins can create and revoke keys. **2. Configure the key** Select **Create API key**, then set: - **Label**: a name that tells you where the key is used, for example `ERP integration`. - **Mode**: **Test** or **Live**. For a live key, confirm that it can create real, billable deliveries. - **Permissions**: only the permissions your integration needs. - **Expiration**: 30, 90, or 365 days, or no expiration. **3. Copy the key** The full key is shown only once. Store it in your secret manager. If you lose it, revoke it and create a new one. Revoking a key in the same section stops it immediately. Integrations using the key fail from then on. > **Note:** > Business workspaces have API access by default. Fleet workspaces need a plan that includes API access. Without it, you can't create keys, and requests with existing keys answer `403 api_access_disabled`. ## Send the key Send the key in the `x-api-key` header, or as a bearer token in the `Authorization` header. The API reference lists both as security schemes: `partnerApiKey` and `partnerBearer`. ```bash x-api-key curl https://api.maxmove.com/v1/vehicle-types \ -H "x-api-key: $MAXMOVE_KEY" ``` ```bash Bearer token curl https://api.maxmove.com/v1/vehicle-types \ -H "Authorization: Bearer $MAXMOVE_KEY" ``` If you send both headers, Maxmove uses `x-api-key`. Keep keys on your server. Never ship them in a browser or mobile app. ## Permissions Each key carries a set of permissions. A request that needs a permission the key doesn't have answers `403 permission_denied`. Listing vehicle types works with every valid key. | Permission | Allows | | --- | --- | | `quotes:create` | `POST /v1/quotes` | | `deliveries:read` | `GET /v1/deliveries`, `GET /v1/deliveries/{deliveryId}`, `…/tracking`, `…/proof-of-delivery` | | `deliveries:write` | `POST /v1/deliveries`, `POST /v1/deliveries/{deliveryId}/cancel` | | `webhooks:manage` | All `/v1/webhooks` endpoints | | `fleet_orders:create` | `POST /v1/fleet/orders` | | `fleet_orders:read` | `GET /v1/fleet/orders/{fleetOrderId}` | The `fleet_orders` permissions are only offered for keys of fleet workspaces, and fleet orders only work with live keys. See [Fleet order ingestion](https://maxmove.com/en/developers/docs/fleet/order-ingestion). ## Live and test data are separate A key only sees data of its own mode. Deliveries, quotes, webhook endpoints, idempotency keys, and `external_id` values created with a test key are invisible to live keys, and the other way round. ## Authentication errors | Status | Code | Meaning | | --- | --- | --- | | 401 | `missing_api_key` | No key in `x-api-key` or `Authorization`. | | 401 | `invalid_api_key` | The key is invalid, expired, or revoked. | | 403 | `permission_denied` | The key lacks the permission for this request. | | 403 | `api_access_disabled` | API access is not part of your workspace's plan. | All error codes are listed in [Errors](https://maxmove.com/en/developers/docs/concepts/errors). --- Source: https://maxmove.com/en/developers/docs/authentication --- # Test mode > Build and test your integration with mm_test_ keys: simulated deliveries, the Robocourier, signed webhooks, and forced failure scenarios. Requests with a test key (`mm_test_…`) go to the same base URL and accept the same requests as live keys. Test deliveries are never dispatched to a courier and never billed. ## The Robocourier A simulated courier, the Robocourier, moves every test delivery through the full status sequence, about 20 seconds per step: | Status | Webhook event sent | | --- | --- | | `pending` | `delivery.created` when the delivery is created | | `courier_assigned` | `delivery.courier_assigned` | | `at_pickup` | `delivery.courier_arrived` | | `picked_up` | `delivery.status_updated` | | `in_transit` | `delivery.status_updated` | | `at_dropoff` | `delivery.courier_arrived` | | `delivered` | `delivery.delivered` | The events are signed exactly like live events, so you can test your [signature verification](https://maxmove.com/en/developers/docs/webhooks/verify-signatures) end to end. Test events only go to webhook endpoints that were registered with a test key. Test deliveries don't send `delivery.eta_updated` or `delivery.pod_submitted`. On the tracking endpoint, the Robocourier's position moves on a straight line from pickup to dropoff. ## Test failure handling Create the delivery with `test_scenario` to make it fail at a fixed point: | `test_scenario` | What happens | | --- | --- | | `courier_cancelled` | The delivery is `cancelled` right after `courier_assigned`. | | `delivery_failed` | The delivery is `cancelled` after `at_dropoff`. | ```json { "pickup": { … }, "dropoff": { … }, "vehicle_type_id": "courier", "test_scenario": "courier_cancelled" } ``` Live keys reject `test_scenario` with `400 invalid_request`. You can also cancel a test delivery yourself with `POST /v1/deliveries/{deliveryId}/cancel` while it is `pending`, `courier_assigned`, or `at_pickup`. ## What is separate in test mode - **Data.** Deliveries, quotes, webhook endpoints, idempotency keys, and `external_id` values of test and live mode are invisible to each other. - **Rate limit.** Test keys can send 120 requests per minute, live keys 600. See [Rate limits](https://maxmove.com/en/developers/docs/concepts/rate-limits). ## Fleet orders have no test mode Fleet orders always land on your real dispatch board, so there is nothing to simulate. `POST /v1/fleet/orders` and `GET /v1/fleet/orders/{fleetOrderId}` answer `403 live_key_required` for test keys. See [Fleet order ingestion](https://maxmove.com/en/developers/docs/fleet/order-ingestion). ## Go live Create a live key in **Settings → API keys** and replace the test key. Register your webhook endpoints again with the live key, because endpoints belong to one mode. Live deliveries are billed on your workspace's monthly invoice; see [Billing](https://maxmove.com/en/developers/docs/billing). --- Source: https://maxmove.com/en/developers/docs/test-mode --- # Delivery lifecycle and statuses > How a Maxmove delivery moves from pending to delivered, what each status means, and how to cancel, track, and fetch proof of delivery. A delivery is one booked courier trip: a pickup, a dropoff, and up to 10 stops in between, in driving order. Create it with [`POST /v1/deliveries`](https://api.maxmove.com/v1/docs#tag/deliveries/POST/v1/deliveries). ## Statuses | Status | Meaning | | --- | --- | | `pending` | Created, Maxmove is looking for a courier. | | `courier_assigned` | A courier accepted and is on the way to pickup. | | `at_pickup` | The courier arrived at pickup. | | `picked_up` | The goods are on board. | | `in_transit` | On the way to the next stop or the dropoff. | | `at_dropoff` | The courier arrived at the dropoff. | | `delivered` | Completed. Final. | | `cancelled` | Cancelled by you or by Maxmove, also when no courier was found. Final. | Statuses only move forward. Your integration should accept any later status, because a delivery can skip intermediate statuses. ## Contacts and instructions Every waypoint (`pickup`, `dropoff`, each entry in `stops`) carries the handoff details for that place: - `contact`: who the courier meets there, with name and phone. Required at pickup and dropoff, optional at stops. - `instructions`: what the courier needs to know there, for example entrance, floor, or gate code (up to 500 characters). Use `notes` on the delivery only for information that concerns the whole delivery. The [API reference](https://api.maxmove.com/v1/docs#tag/deliveries/POST/v1/deliveries) lists every field. For routes with stops, see [Multi-stop deliveries](https://maxmove.com/en/developers/docs/guides/multi-stop). ## Immediate and scheduled deliveries Omit `scheduled_at` for an immediate delivery, or set it to request a pickup time. A scheduled delivery is readable at any time, but the courier position stays hidden until tracking opens: `tracking_available_at` tells you when, and `courier.location` is `null` before that. The [tracking endpoint](https://api.maxmove.com/v1/docs#tag/deliveries/GET/v1/deliveries/{deliveryId}/tracking) is the authoritative source for `tracking_available_at`; list and create responses may return `null` there. See [Scheduled deliveries](https://maxmove.com/en/developers/docs/guides/scheduled-deliveries). ## Track a delivery [`GET /v1/deliveries/{deliveryId}/tracking`](https://api.maxmove.com/v1/docs#tag/deliveries/GET/v1/deliveries/{deliveryId}/tracking) returns the status, the ETA (`eta_seconds`) and remaining distance (`distance_meters`) to the next stop, the remaining route as an encoded polyline, the courier, and a `tracking_url` that you can share with your customer. To react to changes without polling, use [webhooks](https://maxmove.com/en/developers/docs/webhooks/events). See [Tracking](https://maxmove.com/en/developers/docs/guides/tracking) for a complete example. ## Cancel a delivery [`POST /v1/deliveries/{deliveryId}/cancel`](https://api.maxmove.com/v1/docs#tag/deliveries/POST/v1/deliveries/{deliveryId}/cancel) works while the delivery is `pending`, `courier_assigned`, or `at_pickup`. Once the courier has picked up the goods, the request fails with `409 not_cancellable`. See [Cancellations](https://maxmove.com/en/developers/docs/guides/cancellations). ## Proof of delivery When the courier submits proof at pickup or dropoff, Maxmove sends `delivery.pod_submitted`. [`GET /v1/deliveries/{deliveryId}/proof-of-delivery`](https://api.maxmove.com/v1/docs#tag/deliveries/GET/v1/deliveries/{deliveryId}/proof-of-delivery) then returns signatures, photos, and signed delivery notes per pickup and dropoff as short-lived download URLs. Download the files right away if you need to keep them. See [Proof of delivery](https://maxmove.com/en/developers/docs/guides/proof-of-delivery). | Status | Code | Meaning | | --- | --- | --- | | 404 | `no_proof_of_delivery` | The courier has not submitted proof yet. | | 403 | `pod_not_available` | Your plan does not include proof of delivery. | ## Find deliveries - [`GET /v1/deliveries/{deliveryId}`](https://api.maxmove.com/v1/docs#tag/deliveries/GET/v1/deliveries/{deliveryId}) returns one delivery. - [`GET /v1/deliveries`](https://api.maxmove.com/v1/docs#tag/deliveries/GET/v1/deliveries) lists the deliveries of your key's mode, newest first. See [Pagination](https://maxmove.com/en/developers/docs/concepts/conventions#content-pagination). - `external_id` is your own reference, for example a shop order number. It is unique per mode: a second delivery with the same value answers `409 duplicate_external_id`. If you read a delivery while the request that creates it is still running, the API answers `409 request_in_progress`. Retry the create request with the same `Idempotency-Key` to get the result. See [Idempotency](https://maxmove.com/en/developers/docs/concepts/idempotency). --- Source: https://maxmove.com/en/developers/docs/concepts/deliveries --- # Quotes and prices > Price a route before booking, lock the quoted price on the delivery, and handle expired or mismatched quotes. [`POST /v1/quotes`](https://api.maxmove.com/v1/docs#tag/quotes/POST/v1/quotes) prices a route for one vehicle type and returns a `quote_id` (`qt_…`) that is valid for about 5 minutes. `valid_until` in the response tells you until when. Prices are integers in cents of `currency`. `amount_cents` is the total price of the delivery. ## Lock the quoted price Create the delivery with the `quote_id` and the same route to get exactly the quoted price. The route must match the quote in every part: - addresses and coordinates of pickup, dropoff, and stops - the stops and their order - `vehicle_type_id` - `extras` - `items` - `scheduled_at` For a scheduled delivery, send the same `scheduled_at` on the quote, because the price can depend on the pickup time. A `scheduled_at` in the past or too far ahead answers `400 invalid_request`. If the route differs in any way, the request fails with `400 quote_mismatch`. If the quote has expired, it fails with `400 quote_expired`. In both cases, request a new quote and create the delivery with the new `quote_id`. Contacts and instructions are not part of the quote. You add them when you create the delivery. ## Book without a quote You can also create a delivery without `quote_id`. Maxmove then prices the route at creation and returns the price in `amount_cents` of the delivery. ## Check that the goods fit Optionally send `items` with per-unit dimensions and weight, on the quote and on the delivery. Maxmove then checks that the vehicle type can carry them: | Status | Code | Meaning | | --- | --- | --- | | 400 | `vehicle_does_not_fit` | The vehicle type cannot carry the items. Choose a larger vehicle type. | | 400 | `invalid_items` | The items cannot be evaluated. | See [Items and vehicle fit](https://maxmove.com/en/developers/docs/guides/items-and-vehicle-fit). ## Routes outside the service area Maxmove checks the pickup, every stop, and the dropoff against its service areas, on the quote and again on the delivery. A route outside them answers `400 service_area_unavailable`. See [Coverage](https://maxmove.com/en/developers/docs/coverage). ## Quote errors | Status | Code | Meaning | | --- | --- | --- | | 400 | `quote_mismatch` | The route differs from the quoted route. | | 400 | `quote_not_found` | Unknown `quote_id` for this key and mode. | | 400 | `quote_expired` | The quote has expired. Request a new one. | | 400 | `service_area_unavailable` | The pickup, the dropoff, or a stop is outside the service area. | | 409 | `conflict` | Another precondition failed. The message explains why. | Quotes belong to one mode: a quote created with a test key can't be used with a live key. --- Source: https://maxmove.com/en/developers/docs/concepts/quotes --- # Idempotency > Retry delivery and fleet order requests safely with the Idempotency-Key header, without creating duplicates. Network errors and timeouts leave you unsure whether a request reached Maxmove. To retry safely, `POST /v1/deliveries` and `POST /v1/fleet/orders` require an `Idempotency-Key` header of 1 to 255 characters. Use a value that identifies the order in your system, for example your order id. ```bash curl -X POST https://api.maxmove.com/v1/deliveries \ -H "x-api-key: $MAXMOVE_KEY" -H "content-type: application/json" \ -H "Idempotency-Key: order-4711" \ -d @delivery.json ``` ## How retries behave For `POST /v1/deliveries`: | Situation | Response | | --- | --- | | First successful request | `201` with the new delivery. | | Retry with the same key and body | `200` with the original delivery and the header `Idempotent-Replayed: true`. | | Same key with a different body | `409 idempotency_key_reused`. | | Retry while the first request is still running | `409 request_in_progress`. Retry a moment later. | | Header missing | `400 missing_idempotency_key`. | | Header longer than 255 characters | `400 invalid_idempotency_key`. | For `POST /v1/fleet/orders`, the header rules are the same, but a retry with the same key answers `201` with the existing order. Fleet orders don't send `Idempotent-Replayed`. After a timeout, a network error, or a `5xx` answer, retry with the same key and the same body. Don't generate a new key for a retry, or you may create a second delivery. Idempotency keys belong to one mode: the same key used with a test key and a live key creates two independent resources. ## Retry with backoff ```ts Node.js async function createDelivery(body: unknown, idempotencyKey: string) { for (let attempt = 1; attempt <= 5; attempt++) { try { const res = await fetch('https://api.maxmove.com/v1/deliveries', { method: 'POST', headers: { 'x-api-key': process.env.MAXMOVE_KEY!, 'content-type': 'application/json', 'Idempotency-Key': idempotencyKey, }, body: JSON.stringify(body), }); const retryable = res.status === 429 || res.status >= 500 || (res.status === 409 && (await res.clone().json()).error?.code === 'request_in_progress'); if (!retryable) return res; } catch { // Network error: retry with the same key and body. } await new Promise((resolve) => setTimeout(resolve, 2 ** attempt * 500)); } throw new Error('Delivery creation did not succeed after 5 attempts'); } ``` ```python Python import os import time import requests def create_delivery(body: dict, idempotency_key: str) -> requests.Response: for attempt in range(1, 6): try: res = requests.post( "https://api.maxmove.com/v1/deliveries", headers={ "x-api-key": os.environ["MAXMOVE_KEY"], "Idempotency-Key": idempotency_key, }, json=body, timeout=30, ) in_progress = ( res.status_code == 409 and res.json().get("error", {}).get("code") == "request_in_progress" ) if not (res.status_code == 429 or res.status_code >= 500 or in_progress): return res except requests.RequestException: pass # Network error: retry with the same key and body. time.sleep(2**attempt * 0.5) raise RuntimeError("Delivery creation did not succeed after 5 attempts") ``` ```php PHP function createDelivery(array $body, string $idempotencyKey): array { for ($attempt = 1; $attempt <= 5; $attempt++) { $ch = curl_init('https://api.maxmove.com/v1/deliveries'); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30, CURLOPT_HTTPHEADER => [ 'x-api-key: ' . getenv('MAXMOVE_KEY'), 'content-type: application/json', 'Idempotency-Key: ' . $idempotencyKey, ], CURLOPT_POSTFIELDS => json_encode($body), ]); $raw = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); if ($raw !== false) { $json = json_decode($raw, true); $inProgress = $status === 409 && ($json['error']['code'] ?? null) === 'request_in_progress'; if (!($status === 429 || $status >= 500 || $inProgress)) { return ['status' => $status, 'body' => $json]; } } // Network error or retryable answer: retry with the same key and body. usleep((2 ** $attempt) * 500000); } throw new RuntimeException('Delivery creation did not succeed after 5 attempts'); } ``` For `429` answers, wait at least the number of seconds in the `Retry-After` header. See [Rate limits](https://maxmove.com/en/developers/docs/concepts/rate-limits). ## `external_id` `external_id` on a delivery is your own reference and is unique per mode. It is not a replacement for the `Idempotency-Key`: a second delivery with the same `external_id` answers `409 duplicate_external_id`, even if the body is identical. --- Source: https://maxmove.com/en/developers/docs/concepts/idempotency --- # Errors > The Maxmove API error format, every error code with what to do about it, and which errors to retry. Errors use HTTP status codes and one JSON shape: ```json { "error": { "code": "quote_expired", "message": "The quote has expired. Request a new quote and retry.", "doc_url": "https://maxmove.com/en/developers/docs/concepts/errors#content-quote-expired" }, "request_id": "7c9e6679-…" } ``` Branch on `error.code`. The `message` is for people and may change. `doc_url` links to the code's entry on this page. Send the `request_id` when you contact [support](https://maxmove.com/en/developers/docs/support). New error codes can appear within v1. Handle unknown codes by their HTTP status. ## What to retry | Errors | What to do | | --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | | `429` | Wait the number of seconds in `Retry-After`, then retry. | | `500`, `502`, `503`, network errors, timeouts | Retry with backoff. For `POST /v1/deliveries` and `POST /v1/fleet/orders`, reuse the same `Idempotency-Key` and body. | | `409 request_in_progress` | Retry the same request a moment later. | | `402` | Settle your workspace's open statements, then retry. | | Other `4xx` | Don't retry unchanged. Fix the request, or request a new quote for `quote_expired` and `quote_mismatch`. | See [Idempotency](https://maxmove.com/en/developers/docs/concepts/idempotency) for a retry example. ## 400 Bad Request ### `invalid_request` A field is missing or invalid. The message names the field. Fix the request before you send it again. ### `quote_mismatch` The request differs from the quote you passed in `quote_id`: the route, vehicle type, extras, items, or `scheduled_at`. Send exactly the quoted values, or request a new quote. See [Quotes](https://maxmove.com/en/developers/docs/concepts/quotes). ### `quote_not_found` No quote with this `quote_id` exists for this key and mode. Test and live quotes are separate. ### `quote_expired` The quote has expired. Quotes are valid for about 5 minutes. Request a new quote and create the delivery with it. ### `service_area_unavailable` The pickup, the dropoff, or a stop is outside the Maxmove service area. See [Coverage](https://maxmove.com/en/developers/docs/coverage). ### `vehicle_does_not_fit` The chosen vehicle type cannot carry the items. Choose a larger vehicle type. See [Items and vehicle fit](https://maxmove.com/en/developers/docs/guides/items-and-vehicle-fit). ### `invalid_items` The items cannot be evaluated. Check their quantities, weights, and dimensions. ### `missing_idempotency_key` The `Idempotency-Key` header is missing. `POST /v1/deliveries` and `POST /v1/fleet/orders` require it. ### `invalid_idempotency_key` The `Idempotency-Key` header is longer than 255 characters. ### `invalid_page_token` The `page_token` is not valid. Pass the `next_page_token` from the previous page unchanged. ### `invalid_webhook_url` The webhook URL is not a public HTTPS URL. See [Webhook events](https://maxmove.com/en/developers/docs/webhooks/events). ## 401 Unauthorized ### `missing_api_key` The request has no key in `x-api-key` or `Authorization`. See [Authentication](https://maxmove.com/en/developers/docs/authentication). ### `invalid_api_key` The key is invalid, expired, or revoked. Create a new key in the dashboard. ## 402 Payment Required These codes only occur when you create a live delivery, not on quotes. See [Billing](https://maxmove.com/en/developers/docs/billing). ### `invoice_credit_limit_exceeded` The delivery would exceed your workspace's credit limit. Settle open statements or contact support about the limit. ### `invoice_statement_overdue` An invoice statement is overdue. Pay it to book live deliveries again. ## 403 Forbidden ### `permission_denied` The key lacks the permission for this request. Create a key with the permission you need. ### `api_access_disabled` API access is not part of your workspace's plan. ### `invoice_billing_not_enabled` Your workspace is not approved for invoice billing yet, so it can't create live deliveries. Quotes still work. See [Billing](https://maxmove.com/en/developers/docs/billing). ### `fleet_key_required` Fleet orders need a key of a fleet workspace. ### `live_key_required` Fleet orders always create real orders and need a live key. Test keys get this error on create and read. ### `fleet_orders_not_available` Your fleet plan does not include order intake. ### `pod_not_available` Your plan does not include proof of delivery. ## 404 Not Found ### `not_found` The resource does not exist for this key and mode. Test and live resources are separate. ### `no_proof_of_delivery` The courier has not submitted proof of delivery yet. See [Proof of delivery](https://maxmove.com/en/developers/docs/guides/proof-of-delivery). ## 409 Conflict ### `not_cancellable` The delivery can no longer be cancelled, because the courier has already picked up the goods. See [Cancellations](https://maxmove.com/en/developers/docs/guides/cancellations). ### `conflict` The current state does not allow this request. The message explains why. ### `idempotency_key_reused` The `Idempotency-Key` was already used with a different body. Use a new key for a new request. ### `request_in_progress` The first request with this `Idempotency-Key` is still running. Retry the same request a moment later. ### `duplicate_external_id` Another delivery already uses this `external_id`. ### `endpoint_limit_reached` The workspace has the maximum number of webhook endpoints. Delete one you no longer use. ### `endpoint_disabled` The webhook endpoint is disabled. ## 429 Too Many Requests ### `rate_limited` Too many requests. Wait the number of seconds in `Retry-After`, then retry. See [Rate limits](https://maxmove.com/en/developers/docs/concepts/rate-limits). ## 5xx Server errors ### `internal_error` An unexpected error (HTTP 500). Retry with backoff and the same `Idempotency-Key`. If it persists, contact support with the `request_id`. ### `verification_failed` An unexpected verification error (HTTP 500). Retry with backoff and the same `Idempotency-Key`. ### `upstream_error` An internal service failed (HTTP 502). Retry shortly. ### `service_unavailable` The API is temporarily unavailable (HTTP 503). Retry shortly. --- Source: https://maxmove.com/en/developers/docs/concepts/errors --- # Rate limits > Per-key request limits for live and test keys, the rate-limit headers, and how to handle 429 responses. Each API key has its own limit per minute: | Key | Requests per minute | | --- | --- | | Live (`mm_live_…`) | 600 | | Test (`mm_test_…`) | 120 | The limit counts requests in fixed one-minute windows per key. Two keys of the same workspace have separate limits. ## Headers Every authenticated response carries the current state of the key's limit: | Header | Content | | --- | --- | | `X-RateLimit-Limit` | Requests allowed per minute for this key. | | `X-RateLimit-Remaining` | Requests left in the current minute. | | `X-RateLimit-Reset` | Unix time in seconds when the current window resets. | Above the limit, the API answers `429 rate_limited` with a `Retry-After` header in seconds. ## Handle 429 responses Wait at least `Retry-After` seconds before you send the next request with that key. ```ts Node.js async function requestWithRateLimit(url: string, init: RequestInit): Promise { for (;;) { const res = await fetch(url, init); if (res.status !== 429) return res; const waitSeconds = Number(res.headers.get('Retry-After') ?? '1'); await new Promise((resolve) => setTimeout(resolve, waitSeconds * 1000)); } } ``` ```python Python import time import requests def request_with_rate_limit(method: str, url: str, **kwargs) -> requests.Response: while True: res = requests.request(method, url, **kwargs) if res.status_code != 429: return res time.sleep(int(res.headers.get("Retry-After", "1"))) ``` To stay below the limit, prefer [webhooks](https://maxmove.com/en/developers/docs/webhooks/events) over polling the tracking endpoint. --- Source: https://maxmove.com/en/developers/docs/concepts/rate-limits --- # API conventions > Field naming, money, timestamps, ids, coordinates, and pagination in the Maxmove API. ## Requests and responses - JSON bodies with snake_case field names. - Money as an integer amount in cents plus an ISO 4217 `currency`, for example `"amount_cents": 4890, "currency": "EUR"`. - Timestamps in RFC 3339, UTC. - Ids carry a prefix: `qt_` quotes, `del_` deliveries, `we_` webhook endpoints, `evt_` events. - Addresses come with coordinates. Maxmove does not geocode; send the coordinates your address search returned. - New fields can appear in responses at any time. Ignore fields you do not know. ## Pagination `GET /v1/deliveries` returns up to `limit` entries (1 to 20, default 20), newest first, plus `next_page_token`. Pass it as `page_token` to read the next page; it is `null` on the last page. ```bash cURL curl "https://api.maxmove.com/v1/deliveries?limit=20&page_token=$NEXT_PAGE_TOKEN" \ -H "x-api-key: $MAXMOVE_KEY" ``` ```ts Node.js let pageToken: string | null = null; do { const url = new URL('https://api.maxmove.com/v1/deliveries'); url.searchParams.set('limit', '20'); if (pageToken) url.searchParams.set('page_token', pageToken); const res = await fetch(url, { headers: { 'x-api-key': process.env.MAXMOVE_KEY! } }); const page = await res.json(); for (const delivery of page.deliveries) console.log(delivery.id, delivery.status); pageToken = page.next_page_token; } while (pageToken); ``` ```python Python import os import requests page_token = None while True: params = {"limit": 20} if page_token: params["page_token"] = page_token page = requests.get( "https://api.maxmove.com/v1/deliveries", headers={"x-api-key": os.environ["MAXMOVE_KEY"]}, params=params, ).json() for delivery in page["deliveries"]: print(delivery["id"], delivery["status"]) page_token = page["next_page_token"] if not page_token: break ``` An invalid `page_token` answers `400 invalid_page_token`. ## Versioning The major version is part of the path (`/v1`). Within v1, Maxmove only makes additive changes. See [Versioning](https://maxmove.com/en/developers/docs/versioning). --- Source: https://maxmove.com/en/developers/docs/concepts/conventions --- # Multi-stop deliveries > Send one courier to several places in a fixed order: up to 10 stops between pickup and dropoff, each with its own contact and instructions. A multi-stop delivery is one courier trip with intermediate stops between `pickup` and `dropoff`, for example a supplier on the way, or several recipients on one route. Code examples use `BASE_URL` and `headers` from the [Quickstart](https://maxmove.com/en/developers/docs/quickstart). ## Rules - Send up to 10 entries in `stops`, in the order the courier should drive them. - Do not repeat `pickup` or `dropoff` inside `stops`. - Every stop has `address`, `latitude`, and `longitude`. `contact` and `instructions` are optional per stop. They are required at pickup (`contact`) and dropoff (`contact`). - The price covers the whole route, so quote with the same stops you book. ## Quote and book a route with stops **1. Quote the route** Send the stops to [`POST /v1/quotes`](https://api.maxmove.com/v1/docs#tag/quotes/POST/v1/quotes). Quotes take bare locations, without contacts or instructions. ```bash cURL curl -X POST https://api.maxmove.com/v1/quotes \ -H "x-api-key: $MAXMOVE_KEY" -H "content-type: application/json" \ -d '{ "pickup": { "address": "Ehrenstraße 15, 50672 Köln", "latitude": 50.9385, "longitude": 6.9469 }, "stops": [ { "address": "Friedrich-Ebert-Platz 2, 51373 Leverkusen", "latitude": 51.0327, "longitude": 6.9877 } ], "dropoff": { "address": "Königsallee 27, 40212 Düsseldorf", "latitude": 51.2233, "longitude": 6.7768 }, "vehicle_type_id": "courier" }' ``` ```ts Node.js const pickup = { address: 'Ehrenstraße 15, 50672 Köln', latitude: 50.9385, longitude: 6.9469 }; const stop = { address: 'Friedrich-Ebert-Platz 2, 51373 Leverkusen', latitude: 51.0327, longitude: 6.9877 }; const dropoff = { address: 'Königsallee 27, 40212 Düsseldorf', latitude: 51.2233, longitude: 6.7768 }; const quote = await fetch(`${BASE_URL}/quotes`, { method: 'POST', headers, body: JSON.stringify({ pickup, stops: [stop], dropoff, vehicle_type_id: 'courier' }), }).then((res) => res.json()); ``` ```python Python pickup = {"address": "Ehrenstraße 15, 50672 Köln", "latitude": 50.9385, "longitude": 6.9469} stop = {"address": "Friedrich-Ebert-Platz 2, 51373 Leverkusen", "latitude": 51.0327, "longitude": 6.9877} dropoff = {"address": "Königsallee 27, 40212 Düsseldorf", "latitude": 51.2233, "longitude": 6.7768} quote = requests.post( f"{BASE_URL}/quotes", headers=headers, json={"pickup": pickup, "stops": [stop], "dropoff": dropoff, "vehicle_type_id": "courier"}, ).json() ``` **2. Book the same route with handoff details** Send the identical route to [`POST /v1/deliveries`](https://api.maxmove.com/v1/docs#tag/deliveries/POST/v1/deliveries) and add who the courier meets where. With `quote_id`, any difference in addresses, coordinates, or stop order fails with `400 quote_mismatch`. ```ts Node.js const delivery = await fetch(`${BASE_URL}/deliveries`, { method: 'POST', headers: { ...headers, 'Idempotency-Key': 'order-4711' }, body: JSON.stringify({ quote_id: quote.id, pickup: { ...pickup, contact: { name: 'Feinkost Ehrenfeld', phone: '+49 221 1234567' } }, stops: [{ ...stop, instructions: 'Collect two crates at gate 3.' }], dropoff: { ...dropoff, contact: { name: 'Erika Muster', phone: '+49 170 1234567' }, instructions: 'Ring at reception, 2nd floor.', }, vehicle_type_id: 'courier', }), }).then((res) => res.json()); ``` ```python Python delivery = requests.post( f"{BASE_URL}/deliveries", headers={**headers, "Idempotency-Key": "order-4711"}, json={ "quote_id": quote["id"], "pickup": {**pickup, "contact": {"name": "Feinkost Ehrenfeld", "phone": "+49 221 1234567"}}, "stops": [{**stop, "instructions": "Collect two crates at gate 3."}], "dropoff": { **dropoff, "contact": {"name": "Erika Muster", "phone": "+49 170 1234567"}, "instructions": "Ring at reception, 2nd floor.", }, "vehicle_type_id": "courier", }, ).json() ``` ## What changes while the courier drives - The delivery returns `stops` in the order you sent them, each with its `contact` and `instructions` (or `null`). - `delivery.courier_arrived` fires at every waypoint. At pickup and dropoff the status becomes `at_pickup` or `at_dropoff`; at an intermediate stop the status does not change. - [Tracking](https://maxmove.com/en/developers/docs/guides/tracking) reports `eta_seconds` and `distance_meters` to the next waypoint, not to the final dropoff. > **Tip:** > Several independent recipients with different time windows are usually better served by separate deliveries: each one then has its own status, tracking link, and proof of delivery. --- Source: https://maxmove.com/en/developers/docs/guides/multi-stop --- # Scheduled deliveries > Book a pickup for a later time, know when live tracking opens, and handle the delivery until then. Omit `scheduled_at` and the delivery is immediate: Maxmove starts looking for a courier right away. Set `scheduled_at` to request a later pickup time. ## Book a pickup time `scheduled_at` is the requested pickup time in RFC 3339, for example `2026-10-08T08:00:00Z` or `2026-10-08T10:00:00+02:00`. Send it on [`POST /v1/deliveries`](https://api.maxmove.com/v1/docs#tag/deliveries/POST/v1/deliveries) together with the route: ```json { "pickup": { "address": "Ehrenstraße 15, 50672 Köln", "latitude": 50.9385, "longitude": 6.9469, "contact": { "name": "Feinkost Ehrenfeld", "phone": "+49 221 1234567" } }, "dropoff": { "address": "Königsallee 27, 40212 Düsseldorf", "latitude": 51.2233, "longitude": 6.7768, "contact": { "name": "Erika Muster", "phone": "+49 170 1234567" } }, "vehicle_type_id": "courier", "scheduled_at": "2026-10-08T08:00:00Z" } ``` The price can depend on the pickup time, so quote with the same `scheduled_at` you book. [`POST /v1/quotes`](https://api.maxmove.com/v1/docs#tag/quotes/POST/v1/quotes) accepts it, and a delivery whose `scheduled_at` differs from its quote fails with `400 quote_mismatch`. Book within the quote's validity of about 5 minutes. `scheduled_at` must lie in the future. A past time fails with `400 invalid_request`, and so does a time too far ahead. ## Before pickup A scheduled delivery is readable at any time, and you can cancel it until the courier has picked up the goods. It stays `pending` until a courier accepts it. Live tracking opens one hour before the pickup time, or earlier when the courier is already on the way to pickup. Until then `courier.location` is `null`. - Read `tracking_available_at` from [`GET /v1/deliveries/{deliveryId}/tracking`](https://api.maxmove.com/v1/docs#tag/deliveries/GET/v1/deliveries/{deliveryId}/tracking). It is the authoritative time; list and create responses may return `null` there. - Start polling the courier position from that time, or react to `delivery.courier_assigned` and `delivery.courier_arrived` [webhooks](https://maxmove.com/en/developers/docs/webhooks/events). > **Note:** > For live deliveries, the link in `tracking_url` works from creation. Share it with the recipient right away: the page shows the live status, and the courier's position once it is available. ## In test mode The Robocourier does not wait for `scheduled_at`: a scheduled test delivery moves through its statuses right away, about 20 seconds per step. Use it to test your status handling, not your timing. --- Source: https://maxmove.com/en/developers/docs/guides/scheduled-deliveries --- # Items and vehicle fit > Describe what you ship so Maxmove checks that the vehicle can carry it, and the courier, labels, and proof of delivery show the right goods. `items` is optional on quotes, deliveries, and fleet orders. When you send it, Maxmove checks that the chosen `vehicle_type_id` can carry every line before it prices or books the route, and keeps the lines on the delivery for the courier, package labels, and proof of delivery. ## Describe each line One line describes identical units. Send 1 to 100 lines: | Field | Required | Meaning | | --- | --- | --- | | `quantity` | yes | Number of identical units on this line. | | `weight_kg` | yes | Weight of one unit. | | `length_cm`, `width_cm`, `height_cm` | yes | Outer dimensions of one unit. | | `description` | no | Short cargo description shown to the courier. | | `fragile` | no | Never stacked and flagged to the courier. | | `keep_upright` | no | The height axis stays vertical; only the footprint may be rotated. | | `stackable` | no | `true` allows stacking identical units, `false` forbids it. Omit when you don't know. | | `source_line_id`, `sku`, `customer_reference` | no | Your identifiers. `customer_reference` is printed on package labels. | | `barcode`, `gtin`, `sscc` | no | Existing codes. The courier scans `barcode` at pickup and dropoff. | ```json "items": [ { "source_line_id": "SO-4711-1", "description": "Espresso machine, boxed", "quantity": 2, "weight_kg": 12.5, "length_cm": 50, "width_cm": 40, "height_cm": 45, "fragile": true, "keep_upright": true, "barcode": "4006381333931" } ] ``` ## Pick a vehicle that fits `vehicle_type_id` stays required. List the options with [`GET /v1/vehicle-types`](https://api.maxmove.com/v1/docs#tag/vehicle-types/GET/v1/vehicle-types). **1. Quote with the items** Send `items` to [`POST /v1/quotes`](https://api.maxmove.com/v1/docs#tag/quotes/POST/v1/quotes). If the vehicle is too small, the request fails with `400 vehicle_does_not_fit`. **2. Retry with the next larger vehicle** Choose a larger vehicle type and quote again. Line data that cannot be evaluated fails with `400 invalid_items` instead; fix the line and retry. **3. Book with the same items** Send the identical `items` with the `quote_id` to [`POST /v1/deliveries`](https://api.maxmove.com/v1/docs#tag/deliveries/POST/v1/deliveries). Different items fail with `400 quote_mismatch`. > **Tip:** > Send `stackable` only when you know it. Omitting it is treated conservatively, which can mean a larger vehicle than strictly necessary, but never one that is too small. --- Source: https://maxmove.com/en/developers/docs/guides/items-and-vehicle-fit --- # Track deliveries > Share a tracking page with recipients, react to status webhooks, and poll live position and ETA only where you show them. There are three ways to follow a delivery. Most integrations combine them: | Need | Use | | --- | --- | | The recipient wants to see where the courier is | Share `tracking_url`. Maxmove hosts the page. | | Your system needs to know the status | [Webhooks](https://maxmove.com/en/developers/docs/webhooks/events). | | Your own UI shows the courier on a map or an ETA | Poll [`GET /v1/deliveries/{deliveryId}/tracking`](https://api.maxmove.com/v1/docs#tag/deliveries/GET/v1/deliveries/{deliveryId}/tracking). | Code examples use `BASE_URL` and `headers` from the [Quickstart](https://maxmove.com/en/developers/docs/quickstart). ## Share the tracking page Every live delivery has a `tracking_url`, for example `https://maxmove.com/de/track/3J8kQ2pVx9`. Send it to the recipient by e-mail or SMS, or link it from your order page. It needs no login and shows the live status, the courier's position when it is available, and the ETA. ## Read the tracking snapshot The tracking endpoint returns: | Field | Meaning | | --- | --- | | `status` | Current delivery status. | | `eta_seconds` | Expected seconds until the courier reaches the next waypoint. | | `distance_meters` | Remaining driving distance to the next waypoint. | | `route_polyline` | Remaining route as an encoded polyline (Google format). | | `courier.name`, `courier.photo_url`, `courier.rating` | Courier first name, profile photo, and average rating, so the recipient knows who to expect. | | `courier.vehicle` | `make`, `model`, `color`, `license_plate`, and `photo_url` of the vehicle. | | `courier.location` | Latitude, longitude, and `recorded_at`. `null` until `tracking_available_at`. | | `tracking_available_at` | When the position becomes available. Scheduled deliveries open tracking before pickup. | ## Poll only while it matters Poll the endpoint only for deliveries a person is looking at, and only while the courier is on the way: - Start when the status is `courier_assigned`, or at `tracking_available_at` for scheduled deliveries. - Poll every 15 to 30 seconds. Positions don't change faster than that in a useful way. - Stop at `delivered` or `cancelled`. - Use the `delivery.eta_updated` webhook to refresh an ETA you show elsewhere: the event itself carries no ETA. Each key can make 600 requests per minute in live mode. See [Rate limits](https://maxmove.com/en/developers/docs/concepts/rate-limits). ```ts Node.js import { decode } from '@googlemaps/polyline-codec'; const ACTIVE = new Set(['courier_assigned', 'at_pickup', 'picked_up', 'in_transit', 'at_dropoff']); async function pollTracking(deliveryId: string, onUpdate: (snapshot: unknown) => void) { while (true) { const tracking = await fetch(`${BASE_URL}/deliveries/${deliveryId}/tracking`, { headers }).then( (res) => res.json(), ); const route = tracking.route_polyline ? decode(tracking.route_polyline) : []; onUpdate({ ...tracking, route }); if (!ACTIVE.has(tracking.status) && tracking.status !== 'pending') return; await new Promise((resolve) => setTimeout(resolve, 20_000)); } } ``` ```python Python import time import polyline # pip install polyline ACTIVE = {"courier_assigned", "at_pickup", "picked_up", "in_transit", "at_dropoff"} def poll_tracking(delivery_id, on_update): while True: tracking = requests.get(f"{BASE_URL}/deliveries/{delivery_id}/tracking", headers=headers).json() route = polyline.decode(tracking["route_polyline"]) if tracking["route_polyline"] else [] on_update({**tracking, "route": route}) if tracking["status"] not in ACTIVE and tracking["status"] != "pending": return time.sleep(20) ``` > **Note:** > Webhook payloads carry only the courier's name, because they end up in logs. Read the photo, vehicle, and position from the tracking endpoint when you need them. ## In test mode The Robocourier moves in a straight line from pickup to dropoff and reports a position at every step, so you can test a map view without a real courier. --- Source: https://maxmove.com/en/developers/docs/guides/tracking --- # Proof of delivery > Get signatures, photos, and signed delivery notes for pickup and dropoff, and store them in your own system. When the courier hands over the goods, they collect proof in the Maxmove driver app: a signature, photos, a signed delivery note, or documents, depending on your settings. You get it through one event and one endpoint. Code examples use `BASE_URL` and `headers` from the [Quickstart](https://maxmove.com/en/developers/docs/quickstart). **1. Wait for the event** Maxmove sends `delivery.pod_submitted` each time the courier submits proof, at pickup and at dropoff, so up to twice per delivery. Polling is not needed. **2. Fetch the proof** Call [`GET /v1/deliveries/{deliveryId}/proof-of-delivery`](https://api.maxmove.com/v1/docs#tag/deliveries/GET/v1/deliveries/{deliveryId}/proof-of-delivery). The response has one checkpoint per handover: | Field | Meaning | | --- | --- | | `kind` | `pickup` or `dropoff`. | | `recipient_name` | Who signed or received the goods, as entered by the courier. | | `submitted_at` | When the courier submitted the proof. | | `artifacts[]` | Files with `kind` (`photo`, `signature`, `signed_delivery_note`, `document`), `file_name`, `content_type`, and `download_url`. | **3. Download the files right away** `download_url` links are short-lived. Download the files when you receive the event and store them yourself. If a link has expired, call the endpoint again for fresh ones. ```ts Node.js import { writeFile } from 'node:fs/promises'; async function storeProof(deliveryId: string) { const res = await fetch(`${BASE_URL}/deliveries/${deliveryId}/proof-of-delivery`, { headers }); if (res.status === 404) return; // no proof submitted yet const proof = await res.json(); for (const checkpoint of proof.checkpoints) { for (const artifact of checkpoint.artifacts) { if (!artifact.download_url) continue; const file = await fetch(artifact.download_url); await writeFile( `${deliveryId}-${checkpoint.kind}-${artifact.file_name ?? artifact.kind}`, Buffer.from(await file.arrayBuffer()), ); } } } ``` ```python Python def store_proof(delivery_id): res = requests.get(f"{BASE_URL}/deliveries/{delivery_id}/proof-of-delivery", headers=headers) if res.status_code == 404: return # no proof submitted yet for checkpoint in res.json()["checkpoints"]: for artifact in checkpoint["artifacts"]: if not artifact["download_url"]: continue name = artifact["file_name"] or artifact["kind"] with open(f"{delivery_id}-{checkpoint['kind']}-{name}", "wb") as f: f.write(requests.get(artifact["download_url"]).content) ``` ## Errors | Status | Code | Meaning | | --- | --- | --- | | 404 | `no_proof_of_delivery` | The courier has not submitted proof yet. | | 403 | `pod_not_available` | Your plan does not include proof of delivery. | | 404 | `not_found` | No delivery with this id for your key and mode. | --- Source: https://maxmove.com/en/developers/docs/guides/proof-of-delivery --- # Cancellations and failed deliveries > Cancel a delivery in time, handle deliveries that Maxmove cancels or that find no courier, and test every failure path. A delivery ends as `delivered` or `cancelled`. Cancelled covers three cases: you cancelled it, no courier accepted it, or Maxmove ended it. Code examples use `BASE_URL` and `headers` from the [Quickstart](https://maxmove.com/en/developers/docs/quickstart). ## Cancel a delivery Call [`POST /v1/deliveries/{deliveryId}/cancel`](https://api.maxmove.com/v1/docs#tag/deliveries/POST/v1/deliveries/{deliveryId}/cancel) while the status is `pending`, `courier_assigned`, or `at_pickup`. The response is the delivery with status `cancelled`, and Maxmove sends `delivery.cancelled`. Once the courier has picked up the goods, or the delivery has already ended, the request fails with `409 not_cancellable`. Contact Maxmove support in that case. ```ts Node.js const res = await fetch(`${BASE_URL}/deliveries/${deliveryId}/cancel`, { method: 'POST', headers }); if (res.status === 409) { const { error } = await res.json(); if (error.code === 'not_cancellable') { // Too late: the goods are on their way. Escalate to support. } } ``` ```python Python res = requests.post(f"{BASE_URL}/deliveries/{delivery_id}/cancel", headers=headers) if res.status_code == 409 and res.json()["error"]["code"] == "not_cancellable": pass # Too late: the goods are on their way. Escalate to support. ``` ```php PHP $response = $client->post("deliveries/{$deliveryId}/cancel", ['http_errors' => false]); if ($response->getStatusCode() === 409) { $error = json_decode((string) $response->getBody(), true)['error']; if ($error['code'] === 'not_cancellable') { // Too late: the goods are on their way. Escalate to support. } } ``` ## When Maxmove ends a delivery | What happened | Status | Event | What to do | | --- | --- | --- | --- | | No courier accepted the delivery in time | `cancelled` | `delivery.cancelled` | Book again, possibly later or with another vehicle type. | | Maxmove or the courier ended the delivery, for example because the handover failed | `cancelled` | `delivery.cancelled` | Tell your customer and book again if the goods still need to go. | Booking again means a new delivery: use a new `Idempotency-Key`, because the old key returns the old, ended delivery. A new `quote_id` is needed if the old quote has expired. ## Test every path With a test key, `test_scenario` on [`POST /v1/deliveries`](https://api.maxmove.com/v1/docs#tag/deliveries/POST/v1/deliveries) makes the Robocourier fail on purpose: | `test_scenario` | What happens | | --- | --- | | `courier_cancelled` | The courier accepts, then the delivery is cancelled before pickup. | | `delivery_failed` | The courier reaches the dropoff, then the delivery is cancelled. | Cancel a test delivery yourself to test your cancel flow, both before pickup and after (expect `409 not_cancellable`). --- Source: https://maxmove.com/en/developers/docs/guides/cancellations --- # 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 --- # Offer same-day delivery at checkout > A recipe for your own shop: price Maxmove delivery when the customer enters an address, show it only where it is available, and book it at the quoted price once the order is paid. This recipe adds Maxmove as a shipping option to a checkout you build yourself. Shopify, WooCommerce, and Shopware shops get the same flow from the ready-made [integrations](https://maxmove.com/en/developers/docs/integrations). 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. Quote when the address is complete** When the customer has entered a shipping address with coordinates, your server requests a quote. Never call the API from the browser: the API key must stay on your server. **2. Show or hide the option** Show Maxmove delivery with the quoted price. Hide it when the quote fails, for example because the address is outside the service area. **3. Keep the quote with the checkout** Store `id` and `valid_until` of the quote in the checkout session. A quote is valid for about 5 minutes, so request a new one when the customer takes longer. **4. Book once the order is paid** Create the delivery with the `quote_id` and exactly the quoted route, plus the contacts. Then follow [Sync shop orders into deliveries](https://maxmove.com/en/developers/docs/guides/shop-order-sync) for tracking and status updates. ## Quote the address Quote from your store to the customer's address. Send the same `vehicle_type_id`, `items`, and `scheduled_at` you will book later, because all of them are part of the quote. Omit `scheduled_at` for delivery as soon as possible. ```ts Node.js const STORE = { address: 'Ehrenstraße 15, 50672 Köln', latitude: 50.9385, longitude: 6.9469 }; export async function quoteCheckoutDelivery(checkout: Checkout) { const res = await fetch(`${BASE_URL}/quotes`, { method: 'POST', headers, body: JSON.stringify({ pickup: STORE, dropoff: { address: checkout.shippingAddress.formatted, latitude: checkout.shippingAddress.latitude, longitude: checkout.shippingAddress.longitude, }, vehicle_type_id: 'courier', scheduled_at: checkout.deliverySlot ?? undefined, }), }); const body = await res.json(); if (!res.ok) return { available: false, reason: body.error.code }; await saveQuoteOnCheckout(checkout.id, { quoteId: body.id, validUntil: body.valid_until }); return { available: true, amountCents: body.amount_cents, currency: body.currency }; } ``` ```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('quotes', [ 'json' => [ 'pickup' => ['address' => 'Ehrenstraße 15, 50672 Köln', 'latitude' => 50.9385, 'longitude' => 6.9469], 'dropoff' => [ 'address' => $checkout['shipping_address'], 'latitude' => $checkout['shipping_latitude'], 'longitude' => $checkout['shipping_longitude'], ], 'vehicle_type_id' => 'courier', ], ]); $body = json_decode((string) $response->getBody(), true); if ($response->getStatusCode() >= 400) { hide_maxmove_option($checkout['id'], $body['error']['code']); } else { save_quote_on_checkout($checkout['id'], $body['id'], $body['valid_until']); show_maxmove_option($checkout['id'], $body['amount_cents'], $body['currency']); } ``` Maxmove does not geocode. Use the coordinates your address autocomplete returns, and quote again when the customer changes the address. ## Show the price `amount_cents` is the total price in cents of `currency`, including VAT. `breakdown` shows how it is made up if you want to display surcharges or extras. What you charge your customer for shipping is up to you; Maxmove bills you the delivery on your [monthly invoice](https://maxmove.com/en/developers/docs/billing). `duration_min` is the estimated driving time of the route, not a delivery promise. A courier still has to accept the delivery and reach your store. Hide the option when the quote fails: | Status | Code | What to do at checkout | | --- | --- | --- | | 400 | `service_area_unavailable` | The address is outside the [service area](https://maxmove.com/en/developers/docs/coverage). Offer your other shipping methods. | | 400 | `vehicle_does_not_fit` | The items don't fit the vehicle type. Quote a larger one, or hide the option. | | 400 | `invalid_request` | Check the address coordinates and `scheduled_at`. | ## Book the paid order Create the delivery with the stored `quote_id` and the same route you quoted. Add the contacts and instructions, which are not part of the quote. Use your order id as `Idempotency-Key` so a retry never books a second courier. ```ts Node.js export async function bookPaidOrder(order: PaidOrder) { const res = await fetch(`${BASE_URL}/deliveries`, { method: 'POST', headers: { ...headers, 'Idempotency-Key': `shop-order-${order.id}` }, body: JSON.stringify({ quote_id: order.maxmoveQuoteId, external_id: order.number, vehicle_type_id: 'courier', scheduled_at: order.deliverySlot ?? undefined, pickup: { ...STORE, contact: { name: 'Feinkost Ehrenfeld', phone: '+49 221 1234567' }, }, dropoff: { address: order.shippingAddress.formatted, latitude: order.shippingAddress.latitude, longitude: order.shippingAddress.longitude, contact: { name: order.customer.name, phone: order.customer.phone }, }, }), }); const body = await res.json(); if (res.ok) return saveDeliveryOnOrder(order.id, { deliveryId: body.id, trackingUrl: body.tracking_url }); if (body.error.code === 'quote_expired' || body.error.code === 'quote_mismatch') { return bookWithFreshQuote(order); } throw new Error(`${body.error.code}: ${body.error.message} (${body.request_id})`); } ``` ```php PHP $response = $client->post('deliveries', [ 'headers' => ['Idempotency-Key' => 'shop-order-' . $order['id']], 'json' => [ 'quote_id' => $order['maxmove_quote_id'], '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); $code = $body['error']['code'] ?? null; if ($response->getStatusCode() < 400) { save_delivery_on_order($order['id'], $body['id'], $body['tracking_url']); } elseif ($code === 'quote_expired' || $code === 'quote_mismatch') { book_with_fresh_quote($order); } else { throw new RuntimeException("{$code}: {$body['error']['message']} ({$body['request_id']})"); } ``` ## When the quote has expired Customers often pay after the quote's 5 minutes. Then the delivery fails with `400 quote_expired`, and nothing is booked. Decide how your shop handles a price change: - **Request a new quote and book with it.** The price can differ from what the customer saw. Most shops absorb small differences. - **Book without `quote_id`.** Maxmove prices the route when you create the delivery and returns the price in `amount_cents`. A request that fails with `quote_expired` or `quote_mismatch` creates nothing and does not use up its `Idempotency-Key`, so book again with the same key and the new `quote_id`. See [Idempotency](https://maxmove.com/en/developers/docs/concepts/idempotency). ## Test it Test keys accept the same requests and run the same service area check as live keys, but no courier is dispatched and nothing is billed. The Robocourier moves each test delivery through every status, so you can check your order updates end to end. See [Test mode](https://maxmove.com/en/developers/docs/test-mode). --- Source: https://maxmove.com/en/developers/docs/guides/checkout-delivery --- # Webhook events > Register an HTTPS endpoint, choose delivery events, and read the signed event envelope Maxmove sends for every delivery change. Webhooks tell your system about every delivery change as it happens, so you don't need to poll. Maxmove sends a signed `POST` with a JSON body to your endpoint for each event. ## Register an endpoint Register a public HTTPS URL with [`POST /v1/webhooks`](https://api.maxmove.com/v1/docs#tag/webhooks/POST/v1/webhooks). The key needs the `webhooks:manage` permission. List the events you want in `events`, or leave it empty to receive all of them. ```bash cURL curl -X POST https://api.maxmove.com/v1/webhooks \ -H "x-api-key: $MAXMOVE_KEY" -H "content-type: application/json" \ -d '{ "url": "https://shop.example.com/maxmove/webhooks", "events": ["delivery.status_updated", "delivery.delivered", "delivery.cancelled"] }' ``` ```ts Node.js const res = await fetch('https://api.maxmove.com/v1/webhooks', { method: 'POST', headers: { 'x-api-key': process.env.MAXMOVE_KEY!, 'content-type': 'application/json' }, body: JSON.stringify({ url: 'https://shop.example.com/maxmove/webhooks', events: ['delivery.status_updated', 'delivery.delivered', 'delivery.cancelled'], }), }); const endpoint = await res.json(); // Store endpoint.secret now. It is not shown again. ``` ```python Python import os import requests endpoint = requests.post( "https://api.maxmove.com/v1/webhooks", headers={"x-api-key": os.environ["MAXMOVE_KEY"]}, json={ "url": "https://shop.example.com/maxmove/webhooks", "events": ["delivery.status_updated", "delivery.delivered", "delivery.cancelled"], }, ).json() # Store endpoint["secret"] now. It is not shown again. ``` The response contains the endpoint id (`we_…`) and the signing secret (`whsec_…`). The secret is shown only in this response. Store it to [verify signatures](https://maxmove.com/en/developers/docs/webhooks/verify-signatures). - The URL must be a public HTTPS URL. Private and reserved network targets are rejected with `400 invalid_webhook_url`. - Endpoints belong to the mode of the key that registered them. An endpoint registered with a test key only receives test deliveries, and the other way round. - A workspace can register a limited number of endpoints per mode. Above that, registration answers `409 endpoint_limit_reached`. ## Events | Event | Sent when | | --- | --- | | `delivery.created` | The delivery was created. `data.status` is `pending`. | | `delivery.status_updated` | The status changed, for example to `picked_up` or `in_transit`. | | `delivery.courier_assigned` | A courier accepted the delivery. | | `delivery.courier_arrived` | The courier arrived at a waypoint: `data.status` is `at_pickup` or `at_dropoff`. At an intermediate stop, the status doesn't change. | | `delivery.eta_updated` | The schedule or the route changed, so the expected arrival moved. | | `delivery.pod_submitted` | The courier submitted proof at pickup or dropoff. | | `delivery.delivered` | The delivery was completed. `delivered_at` is set. | | `delivery.cancelled` | The delivery was cancelled by you or by Maxmove, also when no courier was found. `cancelled_at` is set. | Events are sent for deliveries created with `POST /v1/deliveries`. [Fleet orders](https://maxmove.com/en/developers/docs/fleet/order-ingestion) don't send webhook events. The [API reference](https://api.maxmove.com/v1/docs#tag/webhook-events) lists the request Maxmove sends for each event type. ## How to read events - **One event per status change.** Each forward status change produces one event. `delivery.eta_updated`, `delivery.pod_submitted`, and `delivery.courier_arrived` at intermediate stops don't change the status. - **Always read `data.status`.** Statuses that have their own event (`courier_assigned`, `at_pickup`, `at_dropoff`, `delivered`, `cancelled`) can also arrive as `delivery.status_updated`, depending on how the underlying updates are ordered. Use `data.status` from any event as the current status, rather than the event type. - **`delivery.courier_arrived` without a status change.** At intermediate stops, the courier's arrival is reported, but `data.status` stays the same. - **`delivery.eta_updated` carries no ETA.** It tells you that the schedule or route changed. Read the new ETA from [`GET /v1/deliveries/{deliveryId}/tracking`](https://api.maxmove.com/v1/docs#tag/deliveries/GET/v1/deliveries/{deliveryId}/tracking). - **`delivery.pod_submitted` per checkpoint.** It is sent once for proof at pickup and once for proof at dropoff, so up to twice per delivery. Fetch the files from [`GET /v1/deliveries/{deliveryId}/proof-of-delivery`](https://api.maxmove.com/v1/docs#tag/deliveries/GET/v1/deliveries/{deliveryId}/proof-of-delivery). ## Event body ```json { "id": "evt_4c1f9e2b7a3d8c5e6f0a1b2c3d4e5f60", "type": "delivery.status_updated", "version": "2026-10", "created_at": "2026-10-07T09:42:11Z", "data": { "id": "del_…", "status": "picked_up", "pickup": { … }, "dropoff": { … }, … } } ``` `data` is the delivery as returned by [`GET /v1/deliveries/{deliveryId}`](https://api.maxmove.com/v1/docs#tag/deliveries/GET/v1/deliveries/{deliveryId}), without the courier's phone number and position: `data.courier` carries only the courier's name. `version` changes only with breaking payload changes. Additive changes, such as new fields in `data`, keep the version. Ignore fields you don't know. See [Versioning](https://maxmove.com/en/developers/docs/versioning). ## Test your endpoint [`POST /v1/webhooks/{webhookId}/test`](https://api.maxmove.com/v1/docs#tag/webhooks/POST/v1/webhooks/{webhookId}/test) sends a signed sample `delivery.status_updated` event to the endpoint. Its `data.id` is `del_sample` and `data.test` is `true`, so your handler can recognize and ignore it. Disabled endpoints answer `409 endpoint_disabled`. With a [test key](https://maxmove.com/en/developers/docs/test-mode), every test delivery also sends real events as the Robocourier moves it along. ## Manage endpoints | Task | Request | | --- | --- | | List endpoints | [`GET /v1/webhooks`](https://api.maxmove.com/v1/docs#tag/webhooks/GET/v1/webhooks) | | Change URL, events, or description | [`PATCH /v1/webhooks/{webhookId}`](https://api.maxmove.com/v1/docs#tag/webhooks/PATCH/v1/webhooks/{webhookId}) | | Pause or resume | `PATCH` with `"status": "disabled"` or `"status": "active"` | | Rotate the signing secret | `PATCH` with `"rotate_secret": true` | | Delete an endpoint | [`DELETE /v1/webhooks/{webhookId}`](https://api.maxmove.com/v1/docs#tag/webhooks/DELETE/v1/webhooks/{webhookId}) | A disabled endpoint receives no new events. See [Retries and replay](https://maxmove.com/en/developers/docs/webhooks/retries) for events that were already queued. --- Source: https://maxmove.com/en/developers/docs/webhooks/events --- # Verify webhook signatures > Check the x-signature HMAC on every Maxmove webhook request with your endpoint's signing secret, and reject stale or forged events. Every webhook request is signed with the signing secret (`whsec_…`) of the endpoint that receives it. Verify the signature before you trust the body. ## Headers | Header | Content | | --- | --- | | `x-event-id` | Event id, same as `id` in the body. | | `x-event-type` | Event type. | | `x-delivery-id` | Delivery id. | | `x-event-timestamp` | Send time, RFC 3339. | | `x-signature` | `sha256=` followed by a hex HMAC-SHA256. | ## How the signature is computed ```text base = "{x-event-id}.{x-delivery-id}.{x-event-timestamp}.{sha256 hex of the raw body}" signature = "sha256=" + hex(HMAC-SHA256(signing_secret, base)) ``` To verify a request: 1. Reject it if `x-event-timestamp` is more than 5 minutes away from your clock. This blocks replayed requests. 2. Hash the **raw** request body with SHA-256, exactly as received and before JSON parsing. 3. Build the base string from the headers and the body hash, and compute the HMAC with your signing secret. 4. Compare the result with `x-signature` in constant time. ```js Node.js import crypto from 'node:crypto'; // rawBody: the request body exactly as received (Buffer or string), before JSON parsing. export function isValidMaxmoveWebhook(headers, rawBody, secret) { const timestamp = headers['x-event-timestamp']; if (Math.abs(Date.now() - Date.parse(timestamp)) > 5 * 60 * 1000) return false; const bodyHash = crypto.createHash('sha256').update(rawBody).digest('hex'); const base = [headers['x-event-id'], headers['x-delivery-id'], timestamp, bodyHash].join('.'); const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(base).digest('hex'); const received = String(headers['x-signature'] ?? ''); return ( received.length === expected.length && crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected)) ); } ``` ```python Python import hashlib import hmac from datetime import datetime, timezone # raw_body: the request body exactly as received (bytes), before JSON parsing. # headers: a case-insensitive mapping, for example Flask's request.headers. def is_valid_maxmove_webhook(headers, raw_body: bytes, secret: str) -> bool: timestamp = headers.get("x-event-timestamp", "") try: sent_at = datetime.fromisoformat(timestamp.replace("Z", "+00:00")) except ValueError: return False if abs((datetime.now(timezone.utc) - sent_at).total_seconds()) > 5 * 60: return False body_hash = hashlib.sha256(raw_body).hexdigest() base = ".".join( [headers.get("x-event-id", ""), headers.get("x-delivery-id", ""), timestamp, body_hash] ) expected = "sha256=" + hmac.new(secret.encode(), base.encode(), hashlib.sha256).hexdigest() return hmac.compare_digest(headers.get("x-signature", ""), expected) ``` ```php PHP getTimestamp()) > 5 * 60) { return false; } $bodyHash = hash('sha256', $rawBody); $base = implode('.', [ $headers['x-event-id'] ?? '', $headers['x-delivery-id'] ?? '', $timestamp, $bodyHash, ]); $expected = 'sha256=' . hash_hmac('sha256', $base, $secret); return hash_equals($expected, $headers['x-signature'] ?? ''); } ``` ## Use the raw body Most frameworks parse JSON before your handler runs. Re-serializing the parsed object changes the bytes and breaks the signature, so read the raw body for verification. ```js Express import express from 'express'; import { isValidMaxmoveWebhook } from './verify.js'; const app = express(); app.post('/maxmove/webhooks', express.raw({ type: 'application/json' }), (req, res) => { if (!isValidMaxmoveWebhook(req.headers, req.body, process.env.MAXMOVE_WEBHOOK_SECRET)) { return res.sendStatus(401); } const event = JSON.parse(req.body.toString('utf8')); res.sendStatus(200); // Process the event after answering, for example by putting it on a queue. queueEvent(event); }); ``` ```python Flask import json import os from flask import Flask, request app = Flask(__name__) @app.post("/maxmove/webhooks") def maxmove_webhook(): raw_body = request.get_data() if not is_valid_maxmove_webhook(request.headers, raw_body, os.environ["MAXMOVE_WEBHOOK_SECRET"]): return "", 401 event = json.loads(raw_body) queue_event(event) # Process asynchronously; answer within 5 seconds. return "", 200 ``` `queueEvent` and `queue_event` stand for your own background processing. Answer within 5 seconds; see [Retries and replay](https://maxmove.com/en/developers/docs/webhooks/retries). ## Check your verification [`POST /v1/webhooks/{webhookId}/test`](https://api.maxmove.com/v1/docs#tag/webhooks/POST/v1/webhooks/{webhookId}/test) sends a signed sample event to your endpoint. Use it to confirm that valid requests pass and that a modified body fails. ## Rotate the secret `PATCH /v1/webhooks/{webhookId}` with `"rotate_secret": true` issues a new signing secret and returns it once in the response. From then on, every request to the endpoint is signed with the new secret, including retries of earlier events. Update your configuration right after rotating. --- Source: https://maxmove.com/en/developers/docs/webhooks/verify-signatures --- # Retries and replay > How Maxmove retries failed webhook deliveries, how to handle duplicates and out-of-order events, and how to replay failed events. ## Answer quickly Answer every webhook request with a `2xx` status within 5 seconds, and process the event afterwards, for example from a queue. A slow answer counts as a failure and is retried. ## Retries | Your endpoint answers | What Maxmove does | | --- | --- | | `2xx` | Done. | | Timeout, network error, `408`, `409`, `425`, `429`, `5xx` | Retries with growing intervals, at most 15 minutes apart, up to 8 attempts in total. | | Any other `4xx` | No retry. The event is kept as failed. | After the last attempt, the event is kept as failed. ## Duplicates and order Events are delivered at least once and not necessarily in order. - **Deduplicate on `id`.** A retried event keeps its `id`, which is also sent in the `x-event-id` header. - **Don't rely on arrival order.** Use `data.status` and `data.updated_at` to decide whether an event is newer than what you already stored. ```ts Node.js type MaxmoveEvent = { id: string; data: { id: string; status: string; updated_at: string }; }; async function handleEvent(event: MaxmoveEvent) { if (await db.processedEvents.exists(event.id)) return; // duplicate const stored = await db.deliveries.find(event.data.id); if (!stored || new Date(event.data.updated_at) > new Date(stored.updated_at)) { await db.deliveries.upsert(event.data); // newer than what we have } await db.processedEvents.insert(event.id); } ``` ```python Python from datetime import datetime def handle_event(event: dict) -> None: if db.processed_events.exists(event["id"]): return # duplicate stored = db.deliveries.find(event["data"]["id"]) updated_at = datetime.fromisoformat(event["data"]["updated_at"].replace("Z", "+00:00")) if stored is None or updated_at > stored.updated_at: db.deliveries.upsert(event["data"]) # newer than what we have db.processed_events.insert(event["id"]) ``` `db` stands for your own storage. ## Replay failed events After you fix an outage on your side, send all failed events of an endpoint again with [`POST /v1/webhooks/{webhookId}/replay`](https://api.maxmove.com/v1/docs#tag/webhooks/POST/v1/webhooks/{webhookId}/replay). The response tells you how many events were queued: ```json { "queued": 3 } ``` Replayed events keep their `id`, so your deduplication still works, and they get the full number of attempts again. Failed events are not kept indefinitely, so replay them soon after you fix the problem. ## Disabled endpoints A disabled endpoint receives no new events. Events that were already queued for it fail without further retries. Enable the endpoint again with `PATCH /v1/webhooks/{webhookId}` and `"status": "active"`, then replay its failed events. ## Missed events Recover events after an outage with [`GET /v1/events`](https://api.maxmove.com/v1/docs#tag/events/GET/v1/events). The list includes events whether or not a webhook endpoint received them, scoped to your workspace and the API key's test or live mode. The key needs `deliveries:read`. Filter by `delivery_id`, `type`, `created_after` (inclusive), or `created_before` (exclusive). Results arrive newest first, up to 100 per page. Pass `next_page_token` as `page_token` until it is `null`, keeping the same filters, and deduplicate by event `id`. To inspect an event and its webhook delivery attempts, use [`GET /v1/events/{eventId}`](https://api.maxmove.com/v1/docs#tag/events/GET/v1/events/{eventId}). Each attempt includes its status, response code, and latency. For the current delivery state, read [`GET /v1/deliveries/{deliveryId}`](https://api.maxmove.com/v1/docs#tag/deliveries/GET/v1/deliveries/{deliveryId}) or [`GET /v1/deliveries`](https://api.maxmove.com/v1/docs#tag/deliveries/GET/v1/deliveries). --- Source: https://maxmove.com/en/developers/docs/webhooks/retries --- # Test webhooks locally > Receive Maxmove webhooks on your laptop through a tunnel, trigger every event with a test key, and replay failed events. Maxmove only sends webhooks to public HTTPS URLs. Private, local, and reserved addresses such as `localhost` or `192.168.x.x` are rejected, both when you register the endpoint and before every send. To test on your machine, put a tunnel in front of your local server. **1. Run a local receiver** Read the raw body before parsing it: the [signature](https://maxmove.com/en/developers/docs/webhooks/verify-signatures) is computed over the exact bytes. ```ts Node.js import { createServer } from 'node:http'; createServer((req, res) => { const chunks: Buffer[] = []; req.on('data', (chunk) => chunks.push(chunk)); req.on('end', () => { const rawBody = Buffer.concat(chunks); console.log(req.headers['webhook-id'], req.headers['webhook-signature']); console.log(rawBody.toString('utf8')); res.writeHead(200).end(); }); }).listen(4242); ``` ```python Python from flask import Flask, request app = Flask(__name__) @app.post("/maxmove/webhooks") def maxmove_webhook(): raw_body = request.get_data() # exact bytes, for signature checks print(request.headers.get("webhook-id"), request.headers.get("webhook-signature")) print(raw_body.decode("utf-8")) return "", 200 app.run(port=4242) ``` **2. Open a tunnel** Any HTTPS tunnel works, for example Cloudflare's quick tunnel or ngrok: ```bash cloudflared tunnel --url http://localhost:4242 ``` Note the public `https://…` URL it prints. **3. Register the endpoint with your test key** Register the tunnel URL with [`POST /v1/webhooks`](https://api.maxmove.com/v1/docs#tag/webhooks/POST/v1/webhooks) using an `mm_test_…` key. Store the `secret` from the response: it is shown only once. An endpoint registered with a test key receives events of test deliveries only. ```bash curl -X POST https://api.maxmove.com/v1/webhooks \ -H "x-api-key: $MAXMOVE_TEST_KEY" -H "content-type: application/json" \ -d '{ "url": "https://your-tunnel.trycloudflare.com/maxmove/webhooks" }' ``` **4. Send a sample event** [`POST /v1/webhooks/{webhookId}/test`](https://api.maxmove.com/v1/docs#tag/webhooks/POST/v1/webhooks/{webhookId}/test) sends one signed `delivery.status_updated` event with `data.test: true`. Use it to check your signature verification. **5. Run a full lifecycle** Create a delivery with the test key. The Robocourier moves it through every status, about 20 seconds per step, and you receive the same signed events as for a live delivery. Add `test_scenario` (`courier_cancelled` or `delivery_failed`) to get the failure paths; see [Cancellations](https://maxmove.com/en/developers/docs/guides/cancellations). ## When your receiver was down Maxmove retries failed sends up to 8 times with growing intervals, at most 15 minutes apart. After that the events are kept as failed. Once your receiver is back, [`POST /v1/webhooks/{webhookId}/replay`](https://api.maxmove.com/v1/docs#tag/webhooks/POST/v1/webhooks/{webhookId}/replay) sends all of them again. See [Retries](https://maxmove.com/en/developers/docs/webhooks/retries). > **Warning:** > Quick tunnel URLs change on every start. Update the endpoint with [`PATCH /v1/webhooks/{webhookId}`](https://api.maxmove.com/v1/docs#tag/webhooks/PATCH/v1/webhooks/{webhookId}) instead of registering a new one each time, or delete old endpoints: a workspace can have at most 10 per mode (live and test). ## Example payloads One example per event type, for a live delivery from Köln to Düsseldorf. `data` is the delivery as returned by [`GET /v1/deliveries/{deliveryId}`](https://api.maxmove.com/v1/docs#tag/deliveries/GET/v1/deliveries/{deliveryId}), with `courier` reduced to the courier's name. Read `data.status` rather than the event type alone: events can arrive out of order, and a status change can arrive as `delivery.status_updated`. ```json delivery.created { "id": "evt_014c1f9e2b7a3d8c5e6f0a1b2c3d4e5f", "type": "delivery.created", "version": "2026-10", "created_at": "2026-10-07T09:30:00Z", "data": { "id": "del_0c9b0f6e2d8a4e1f9b7c5a3d1e2f4a6b", "status": "pending", "mode": "live", "external_id": "order-4711", "quote_id": "qt_8f14e45fceea167a5a36dedd4bea2543", "amount_cents": 4890, "currency": "EUR", "pickup": { "address": "Ehrenstraße 15, 50672 Köln", "latitude": 50.9385, "longitude": 6.9469, "contact": { "name": "Feinkost Ehrenfeld", "phone": "+49 221 1234567" }, "instructions": "Pick up at the back entrance." }, "dropoff": { "address": "Königsallee 27, 40212 Düsseldorf", "latitude": 51.2233, "longitude": 6.7768, "contact": { "name": "Erika Muster", "phone": "+49 170 1234567" }, "instructions": "Ring at reception, 2nd floor." }, "stops": [], "vehicle_type_id": "courier", "notes": null, "scheduled_at": null, "tracking_available_at": null, "tracking_url": "https://maxmove.com/de/track/3J8kQ2pVx9", "courier": null, "created_at": "2026-10-07T09:30:00Z", "updated_at": "2026-10-07T09:30:00Z", "cancelled_at": null, "delivered_at": null } } ``` ```json delivery.courier_assigned { "id": "evt_024c1f9e2b7a3d8c5e6f0a1b2c3d4e5f", "type": "delivery.courier_assigned", "version": "2026-10", "created_at": "2026-10-07T09:32:10Z", "data": { "id": "del_0c9b0f6e2d8a4e1f9b7c5a3d1e2f4a6b", "status": "courier_assigned", "mode": "live", "external_id": "order-4711", "quote_id": "qt_8f14e45fceea167a5a36dedd4bea2543", "amount_cents": 4890, "currency": "EUR", "pickup": { "address": "Ehrenstraße 15, 50672 Köln", "latitude": 50.9385, "longitude": 6.9469, "contact": { "name": "Feinkost Ehrenfeld", "phone": "+49 221 1234567" }, "instructions": "Pick up at the back entrance." }, "dropoff": { "address": "Königsallee 27, 40212 Düsseldorf", "latitude": 51.2233, "longitude": 6.7768, "contact": { "name": "Erika Muster", "phone": "+49 170 1234567" }, "instructions": "Ring at reception, 2nd floor." }, "stops": [], "vehicle_type_id": "courier", "notes": null, "scheduled_at": null, "tracking_available_at": null, "tracking_url": "https://maxmove.com/de/track/3J8kQ2pVx9", "courier": { "name": "Jonas" }, "created_at": "2026-10-07T09:30:00Z", "updated_at": "2026-10-07T09:32:10Z", "cancelled_at": null, "delivered_at": null } } ``` ```json delivery.courier_arrived { "id": "evt_034c1f9e2b7a3d8c5e6f0a1b2c3d4e5f", "type": "delivery.courier_arrived", "version": "2026-10", "created_at": "2026-10-07T09:48:05Z", "data": { "id": "del_0c9b0f6e2d8a4e1f9b7c5a3d1e2f4a6b", "status": "at_pickup", "mode": "live", "external_id": "order-4711", "quote_id": "qt_8f14e45fceea167a5a36dedd4bea2543", "amount_cents": 4890, "currency": "EUR", "pickup": { "address": "Ehrenstraße 15, 50672 Köln", "latitude": 50.9385, "longitude": 6.9469, "contact": { "name": "Feinkost Ehrenfeld", "phone": "+49 221 1234567" }, "instructions": "Pick up at the back entrance." }, "dropoff": { "address": "Königsallee 27, 40212 Düsseldorf", "latitude": 51.2233, "longitude": 6.7768, "contact": { "name": "Erika Muster", "phone": "+49 170 1234567" }, "instructions": "Ring at reception, 2nd floor." }, "stops": [], "vehicle_type_id": "courier", "notes": null, "scheduled_at": null, "tracking_available_at": null, "tracking_url": "https://maxmove.com/de/track/3J8kQ2pVx9", "courier": { "name": "Jonas" }, "created_at": "2026-10-07T09:30:00Z", "updated_at": "2026-10-07T09:48:05Z", "cancelled_at": null, "delivered_at": null } } ``` ```json delivery.status_updated { "id": "evt_044c1f9e2b7a3d8c5e6f0a1b2c3d4e5f", "type": "delivery.status_updated", "version": "2026-10", "created_at": "2026-10-07T09:52:40Z", "data": { "id": "del_0c9b0f6e2d8a4e1f9b7c5a3d1e2f4a6b", "status": "picked_up", "mode": "live", "external_id": "order-4711", "quote_id": "qt_8f14e45fceea167a5a36dedd4bea2543", "amount_cents": 4890, "currency": "EUR", "pickup": { "address": "Ehrenstraße 15, 50672 Köln", "latitude": 50.9385, "longitude": 6.9469, "contact": { "name": "Feinkost Ehrenfeld", "phone": "+49 221 1234567" }, "instructions": "Pick up at the back entrance." }, "dropoff": { "address": "Königsallee 27, 40212 Düsseldorf", "latitude": 51.2233, "longitude": 6.7768, "contact": { "name": "Erika Muster", "phone": "+49 170 1234567" }, "instructions": "Ring at reception, 2nd floor." }, "stops": [], "vehicle_type_id": "courier", "notes": null, "scheduled_at": null, "tracking_available_at": null, "tracking_url": "https://maxmove.com/de/track/3J8kQ2pVx9", "courier": { "name": "Jonas" }, "created_at": "2026-10-07T09:30:00Z", "updated_at": "2026-10-07T09:52:40Z", "cancelled_at": null, "delivered_at": null } } ``` ```json delivery.eta_updated { "id": "evt_054c1f9e2b7a3d8c5e6f0a1b2c3d4e5f", "type": "delivery.eta_updated", "version": "2026-10", "created_at": "2026-10-07T10:05:12Z", "data": { "id": "del_0c9b0f6e2d8a4e1f9b7c5a3d1e2f4a6b", "status": "in_transit", "mode": "live", "external_id": "order-4711", "quote_id": "qt_8f14e45fceea167a5a36dedd4bea2543", "amount_cents": 4890, "currency": "EUR", "pickup": { "address": "Ehrenstraße 15, 50672 Köln", "latitude": 50.9385, "longitude": 6.9469, "contact": { "name": "Feinkost Ehrenfeld", "phone": "+49 221 1234567" }, "instructions": "Pick up at the back entrance." }, "dropoff": { "address": "Königsallee 27, 40212 Düsseldorf", "latitude": 51.2233, "longitude": 6.7768, "contact": { "name": "Erika Muster", "phone": "+49 170 1234567" }, "instructions": "Ring at reception, 2nd floor." }, "stops": [], "vehicle_type_id": "courier", "notes": null, "scheduled_at": null, "tracking_available_at": null, "tracking_url": "https://maxmove.com/de/track/3J8kQ2pVx9", "courier": { "name": "Jonas" }, "created_at": "2026-10-07T09:30:00Z", "updated_at": "2026-10-07T10:05:12Z", "cancelled_at": null, "delivered_at": null } } ``` ```json delivery.pod_submitted { "id": "evt_064c1f9e2b7a3d8c5e6f0a1b2c3d4e5f", "type": "delivery.pod_submitted", "version": "2026-10", "created_at": "2026-10-07T10:31:58Z", "data": { "id": "del_0c9b0f6e2d8a4e1f9b7c5a3d1e2f4a6b", "status": "at_dropoff", "mode": "live", "external_id": "order-4711", "quote_id": "qt_8f14e45fceea167a5a36dedd4bea2543", "amount_cents": 4890, "currency": "EUR", "pickup": { "address": "Ehrenstraße 15, 50672 Köln", "latitude": 50.9385, "longitude": 6.9469, "contact": { "name": "Feinkost Ehrenfeld", "phone": "+49 221 1234567" }, "instructions": "Pick up at the back entrance." }, "dropoff": { "address": "Königsallee 27, 40212 Düsseldorf", "latitude": 51.2233, "longitude": 6.7768, "contact": { "name": "Erika Muster", "phone": "+49 170 1234567" }, "instructions": "Ring at reception, 2nd floor." }, "stops": [], "vehicle_type_id": "courier", "notes": null, "scheduled_at": null, "tracking_available_at": null, "tracking_url": "https://maxmove.com/de/track/3J8kQ2pVx9", "courier": { "name": "Jonas" }, "created_at": "2026-10-07T09:30:00Z", "updated_at": "2026-10-07T10:31:58Z", "cancelled_at": null, "delivered_at": null } } ``` ```json delivery.delivered { "id": "evt_074c1f9e2b7a3d8c5e6f0a1b2c3d4e5f", "type": "delivery.delivered", "version": "2026-10", "created_at": "2026-10-07T10:33:20Z", "data": { "id": "del_0c9b0f6e2d8a4e1f9b7c5a3d1e2f4a6b", "status": "delivered", "mode": "live", "external_id": "order-4711", "quote_id": "qt_8f14e45fceea167a5a36dedd4bea2543", "amount_cents": 4890, "currency": "EUR", "pickup": { "address": "Ehrenstraße 15, 50672 Köln", "latitude": 50.9385, "longitude": 6.9469, "contact": { "name": "Feinkost Ehrenfeld", "phone": "+49 221 1234567" }, "instructions": "Pick up at the back entrance." }, "dropoff": { "address": "Königsallee 27, 40212 Düsseldorf", "latitude": 51.2233, "longitude": 6.7768, "contact": { "name": "Erika Muster", "phone": "+49 170 1234567" }, "instructions": "Ring at reception, 2nd floor." }, "stops": [], "vehicle_type_id": "courier", "notes": null, "scheduled_at": null, "tracking_available_at": null, "tracking_url": "https://maxmove.com/de/track/3J8kQ2pVx9", "courier": { "name": "Jonas" }, "created_at": "2026-10-07T09:30:00Z", "updated_at": "2026-10-07T10:33:20Z", "cancelled_at": null, "delivered_at": "2026-10-07T10:33:20Z" } } ``` ```json delivery.cancelled { "id": "evt_084c1f9e2b7a3d8c5e6f0a1b2c3d4e5f", "type": "delivery.cancelled", "version": "2026-10", "created_at": "2026-10-07T09:31:15Z", "data": { "id": "del_0c9b0f6e2d8a4e1f9b7c5a3d1e2f4a6b", "status": "cancelled", "mode": "live", "external_id": "order-4711", "quote_id": "qt_8f14e45fceea167a5a36dedd4bea2543", "amount_cents": 4890, "currency": "EUR", "pickup": { "address": "Ehrenstraße 15, 50672 Köln", "latitude": 50.9385, "longitude": 6.9469, "contact": { "name": "Feinkost Ehrenfeld", "phone": "+49 221 1234567" }, "instructions": "Pick up at the back entrance." }, "dropoff": { "address": "Königsallee 27, 40212 Düsseldorf", "latitude": 51.2233, "longitude": 6.7768, "contact": { "name": "Erika Muster", "phone": "+49 170 1234567" }, "instructions": "Ring at reception, 2nd floor." }, "stops": [], "vehicle_type_id": "courier", "notes": null, "scheduled_at": null, "tracking_available_at": null, "tracking_url": "https://maxmove.com/de/track/3J8kQ2pVx9", "courier": null, "created_at": "2026-10-07T09:30:00Z", "updated_at": "2026-10-07T09:31:15Z", "cancelled_at": "2026-10-07T09:31:15Z", "delivered_at": null } } ``` --- Source: https://maxmove.com/en/developers/docs/guides/webhooks-local-testing --- # Go-live checklist > What to check before you switch your integration from a test key to a live key. Test keys and live keys use the same base URL, payloads, and events. Switching means replacing the key and registering live webhook endpoints. Before you do, go through this list. ## Keys - Create a separate live key per system that calls Maxmove, with only the permissions it needs (`quotes:create`, `deliveries:read`, `deliveries:write`, `webhooks:manage`). See [Security](https://maxmove.com/en/developers/docs/guides/security). - Store the key in your secret manager or environment, never in code, a mobile app, or a browser. - Note who owns the key and when it expires, if you set an expiry date. ## Workspace - Your workspace is approved for invoice billing. Without approval, live deliveries fail with `403 invoice_billing_not_enabled`; contact Maxmove support. - Every pickup and dropoff you plan to serve lies in the Maxmove service area. Outside it, requests fail with `400 service_area_unavailable`. - Your pickup locations have a `contact` with a phone number that someone answers during pickup hours. ## Requests - Every create request sends an `Idempotency-Key` that is stable per order, and retries reuse it. See [Idempotency](https://maxmove.com/en/developers/docs/concepts/idempotency). - Every address comes with the coordinates of your address search. - If you quote first, you book with the same route and `scheduled_at` within about 5 minutes, and handle `quote_expired` by quoting again. - Your code branches on `error.code`, never on the message, and logs `request_id` with every failed call. - Retries: `429`, `502`, and `503` are retried with backoff, honoring `Retry-After`. `409 request_in_progress` is retried shortly. Other `4xx` responses are not retried until the request is fixed. See [Errors](https://maxmove.com/en/developers/docs/concepts/errors). ## Webhooks - A live endpoint is registered with your live key. Test endpoints only receive test events. - Your receiver verifies the signature over the raw body and rejects old timestamps. See [Verify signatures](https://maxmove.com/en/developers/docs/webhooks/verify-signatures). - It answers `2xx` within 5 seconds and does the work afterwards. - It deduplicates on the event `id` and reads `data.status` instead of relying on arrival order. - You know how to replay failed events after an outage with `POST /v1/webhooks/{webhookId}/replay`. See [Retries](https://maxmove.com/en/developers/docs/webhooks/retries). ## Rehearse in test mode - A full test delivery reaches `delivered` in your system, including proof of delivery. - Both failure scenarios (`test_scenario: courier_cancelled` and `delivery_failed`) end as `cancelled` in your system and reach a person. - Cancelling before pickup works, and cancelling after pickup shows the `409 not_cancellable` path. ## First live deliveries - Book the first live deliveries during business hours, with a short route you can watch on the tracking page. - Keep an eye on failed requests and webhook errors in your logs for the first days. > **Note:** > Fleet orders have no test mode. `POST /v1/fleet/orders` needs a live key and creates real orders on your dispatch board. --- Source: https://maxmove.com/en/developers/docs/guides/go-live-checklist --- # Security best practices > Keep API keys and webhook secrets safe, limit what each key can do, rotate both without downtime, and handle personal data carefully. ## API keys An API key acts for your whole workspace: whoever holds a live key can book deliveries that are invoiced to you. - **Server-side only.** Call the API from your backend. Never put a key into a website, mobile app, or desktop app, and never commit it to a repository. - **Store it as a secret,** in your secret manager or deployment environment. - **One key per system.** A separate key for the shop, the ERP, and each environment lets you revoke one without breaking the others. - **Least privilege.** Give each key only the permissions it needs: | Permission | Allows | | --- | --- | | `quotes:create` | Quote routes. | | `deliveries:read` | Read deliveries, tracking, and proof of delivery. | | `deliveries:write` | Create and cancel deliveries. | | `webhooks:manage` | Create, change, test, and delete webhook endpoints. | | `fleet_orders:create`, `fleet_orders:read` | Fleets only: push and read orders on your dispatch board. | A missing permission answers `403 permission_denied`. - **Expiry dates.** Give keys for temporary integrations or contractors an expiry date when you create them. ### Rotate a key **1. Create the new key** Create a second key with the same permissions in **Settings → API keys**. **2. Deploy it** Switch your system to the new key. Both keys work in parallel. **3. Revoke the old key** Once no request uses the old key anymore, revoke it. Requests with a revoked key answer `401 invalid_api_key`. ## Webhook secrets - The signing secret (`whsec_…`) is returned once, when you create the endpoint. Store it like an API key. - [Verify every request](https://maxmove.com/en/developers/docs/webhooks/verify-signatures): recompute the HMAC over the raw body, compare in constant time, and reject timestamps older than 5 minutes. - Don't put secrets or tokens into the webhook URL. Maxmove signs every request, so the URL needs no protection of its own. ### Rotate a webhook secret [`PATCH /v1/webhooks/{webhookId}`](https://api.maxmove.com/v1/docs#tag/webhooks/PATCH/v1/webhooks/{webhookId}) with `"rotate_secret": true` returns the new secret once. For the next 24 hours, every event carries two signatures in `webhook-signature`, one per secret, so your verifier accepts it with whichever secret is deployed. `previous_secret_expires_at` on the endpoint shows when the old secret stops signing. Deploy the new secret within that window. Standard Webhooks libraries already accept any matching signature in the header. ## Webhook URLs Maxmove only sends webhooks to public HTTPS URLs. Private, local, and reserved addresses are rejected when you register the endpoint and checked again, including DNS, before every send. ## Personal data Deliveries contain personal data: names, phone numbers, addresses, and proof-of-delivery photos and signatures. - Store only what your process needs, and delete it on your usual retention schedule. - Download proof-of-delivery files into storage with the same access rules as your other customer data. The download links expire quickly by design. - Webhook payloads carry only the courier's name, because webhooks often end up in logs. Read the courier's photo, vehicle, and position from the tracking endpoint when needed. ## Report a security issue Report security findings to Maxmove support at [support@maxmove.com](mailto:support@maxmove.com). --- Source: https://maxmove.com/en/developers/docs/guides/security --- # Billing > How deliveries booked through the Maxmove API are billed: monthly invoice, invoice approval and credit limit, what is charged, and the billing errors. Deliveries you create with a live key are billed to the workspace that owns the key, on its monthly invoice. Test keys are never billed. ## How API deliveries are paid - **Monthly invoice.** Every live delivery from `POST /v1/deliveries` is booked on invoice with monthly billing. There is no card payment per delivery through the API. - **Only completed deliveries.** A delivery is added to the monthly statement when it is completed, in the calendar month (UTC) of completion. Cancelled deliveries are not added. - **Price.** `amount_cents` on the quote and the delivery is the total price in cents of `currency`. Prices include applicable VAT. - **Quoted price.** Book with a `quote_id` to get exactly the quoted price. See [Quotes and prices](https://maxmove.com/en/developers/docs/concepts/quotes). ## Invoice approval Before you can create live deliveries, Maxmove must approve your workspace for invoice billing and set a credit limit. Contact [support](https://maxmove.com/en/developers/docs/support) to enable it. Quotes work without approval. Live delivery creation checks it: | Status | Code | Meaning | What to do | | --- | --- | --- | --- | | 403 | `invoice_billing_not_enabled` | Your workspace is not approved for invoice billing yet. | Contact support to enable invoice billing. | | 402 | `invoice_credit_limit_exceeded` | The delivery would exceed your workspace's credit limit. | Settle open statements or contact support. | | 402 | `invoice_statement_overdue` | An invoice statement is overdue. | Pay the outstanding balance, then retry. | These errors don't occur with test keys, so check approval before you go live. ## API access Business workspaces have API access by default. Fleet workspaces need a plan that includes API access; otherwise requests answer `403 api_access_disabled`. See [Authentication](https://maxmove.com/en/developers/docs/authentication). ## Shop integrations are billed differently Orders from the Shopify, WooCommerce, and Shopware integrations are not billed on the monthly invoice. Maxmove charges the workspace's company card per delivery. See [Integrations](https://maxmove.com/en/developers/docs/integrations). --- Source: https://maxmove.com/en/developers/docs/billing --- # Coverage > How Maxmove decides whether a route is inside its service area, where it operates today, and how the API reports routes it can't serve. Maxmove serves defined service areas. Whether a route can be booked is decided per request, so check it with a quote instead of keeping your own list of cities. ## Where Maxmove operates Most orders are in North Rhine-Westphalia (NRW). Pickups are mainly in cities such as Cologne, Düsseldorf, Dortmund, Essen, Duisburg, Bochum, Wuppertal, Bonn, Münster, and Aachen. Coverage expands in phases. See [Service availability and city coverage](https://maxmove.com/en/help/customer-getting-started/customer-service-availability-cities) in the help center. ## How a route is checked Maxmove keeps its service areas in a catalog that its operations team maintains. The catalog defines areas for pickups and for dropoffs, and exclusion zones. Changes to the catalog take effect without an API release. For every quote and every delivery, Maxmove checks: - the pickup, - every stop, - the dropoff. If any of them is outside the service area, or inside an exclusion zone, the request answers `400 service_area_unavailable`: ```json { "error": { "code": "service_area_unavailable", "message": "The pickup, the dropoff, or a stop is outside the Maxmove service area." }, "request_id": "7c9e6679-…" } ``` ## Check a route Request a quote with [`POST /v1/quotes`](https://api.maxmove.com/v1/docs#tag/quotes/POST/v1/quotes). A quote changes nothing and isn't billed, so it is the simplest serviceability check. If the quote succeeds, the route was inside the service area at that moment. Maxmove checks the route again when you create the delivery. In a shop checkout, hide or disable Maxmove delivery when the quote answers `service_area_unavailable`, and offer your other shipping methods. ## Test mode Test keys use the same service area check as live keys, so an unserviceable route also fails in test mode. --- Source: https://maxmove.com/en/developers/docs/coverage --- # Fleet order ingestion > For fleets on Maxmove: let your customers' ERP, shop, or e-mail parser push orders straight onto your own dispatch board. If you run a fleet on Maxmove, your customers' systems can send orders to you through the API. [`POST /v1/fleet/orders`](https://api.maxmove.com/v1/docs#tag/fleet-orders/POST/v1/fleet/orders) pushes an order from your customer's system (ERP, shop, or e-mail parser) straight onto your own dispatch board, without the marketplace. This is the carrier side of the API. To book Maxmove couriers as a shipper, use [deliveries](https://maxmove.com/en/developers/docs/concepts/deliveries) instead. ## Requirements - A key of a **fleet workspace**. Keys of business workspaces answer `403 fleet_key_required`. - A **live key** (`mm_live_…`). Fleet orders always land on your real dispatch board, so there is no test mode for them: test keys answer `403 live_key_required`, both when creating and when reading orders. - The `fleet_orders:create` permission to create orders, and `fleet_orders:read` to read them. See [Authentication](https://maxmove.com/en/developers/docs/authentication#content-permissions). - A fleet plan that includes order intake. Otherwise requests answer `403 fleet_orders_not_available`. ## Create an order An order carries: - `customer`: your customer who placed the order. - `pickup` and `dropoff`, and optionally up to 10 `stops`: each with address and coordinates, plus optional `contact` and `instructions` for that place. - `vehicle_type_id`: an id from [`GET /v1/vehicle-types`](https://api.maxmove.com/v1/docs#tag/vehicle-types/GET/v1/vehicle-types). - Optional `items`, your customer's `reference`, a requested pickup date and time, and `notes`. The [API reference](https://api.maxmove.com/v1/docs#tag/fleet-orders/POST/v1/fleet/orders) lists every field with its format. The `Idempotency-Key` header is required. Use your customer's order id, for example. A retry with the same key answers `201` with the existing order instead of creating a second one. See [Idempotency](https://maxmove.com/en/developers/docs/concepts/idempotency). ```bash cURL curl -X POST https://api.maxmove.com/v1/fleet/orders \ -H "x-api-key: $MAXMOVE_FLEET_KEY" -H "content-type: application/json" \ -H "Idempotency-Key: PO-2026-1187" \ -d @order.json ``` ```ts Node.js const res = await fetch('https://api.maxmove.com/v1/fleet/orders', { method: 'POST', headers: { 'x-api-key': process.env.MAXMOVE_FLEET_KEY!, 'content-type': 'application/json', 'Idempotency-Key': 'PO-2026-1187', }, body: JSON.stringify(order), }); const fleetOrder = await res.json(); console.log(res.status, fleetOrder.id, fleetOrder.status); ``` ```python Python import os import requests fleet_order = requests.post( "https://api.maxmove.com/v1/fleet/orders", headers={ "x-api-key": os.environ["MAXMOVE_FLEET_KEY"], "Idempotency-Key": "PO-2026-1187", }, json=order, ).json() print(fleet_order["id"], fleet_order["status"]) ``` `order` stands for the request body described above. If you send `items`, Maxmove checks that the vehicle type can carry them and answers `400 vehicle_does_not_fit` when it cannot. ## Read an order [`GET /v1/fleet/orders/{fleetOrderId}`](https://api.maxmove.com/v1/docs#tag/fleet-orders/GET/v1/fleet/orders/{fleetOrderId}) returns the order as it stands on your dispatch board, with its status, your `reference`, and, when available, a tracking URL. Fleet orders don't send webhook events. Read the order to check its progress. ## Errors | Status | Code | Meaning | | --- | --- | --- | | 403 | `fleet_key_required` | The key belongs to a business workspace. Use a key of your fleet workspace. | | 403 | `live_key_required` | The key is a test key. Use a live key. | | 403 | `fleet_orders_not_available` | Your fleet plan does not include order intake. | | 403 | `permission_denied` | The key lacks `fleet_orders:create` or `fleet_orders:read`. | | 400 | `vehicle_does_not_fit` | The vehicle type cannot carry the items. | All other codes are listed in [Errors](https://maxmove.com/en/developers/docs/concepts/errors). --- Source: https://maxmove.com/en/developers/docs/fleet/order-ingestion --- # Integrations > How the ready-made Maxmove integrations for Shopify, WooCommerce, Shopware, ChatGPT, Claude, and DocuWare connect, sync data, and get paid, and when to build on the API instead. Maxmove offers ready-made integrations for shop systems, AI assistants, and document archives. Merchants and fleets set them up without code; the click-by-click setup guides live in the [help center](https://maxmove.com/en/help). These pages explain what each integration does under the hood, for developers who support, extend, or evaluate them. > **Note:** > None of these integrations uses the public `/v1` API or `/v1` API keys. Each one has its own connection and authentication. Webhook events from [`/v1/webhooks`](https://maxmove.com/en/developers/docs/webhooks/events) are not sent for their orders. ## Shop systems - [Shopify](https://maxmove.com/en/developers/docs/integrations/shopify): Checkout rates, order import, and fulfillment write-back. - [WooCommerce](https://maxmove.com/en/developers/docs/integrations/woocommerce): Shipping method, order sync, and status webhooks. - [Shopware](https://maxmove.com/en/developers/docs/integrations/shopware): Plugin for self-hosted shops and app for Shopware Cloud. All three shop integrations work the same way at the core: | | Shopify, WooCommerce, Shopware | | --- | --- | | Workspace | One business workspace with the Integrations and Payments features. | | Checkout | Maxmove prices each cart live and shows the result as a shipping rate. The shopper sees the price from your storefront pricing policy, not necessarily the carrier price. | | Order | A paid order with the Maxmove shipping rate becomes one Maxmove delivery. | | Payment | The shopper pays your shop. Maxmove charges your workspace's company card per delivery: it authorizes the amount when the order is imported and captures it after completion. | | Status | Maxmove writes the tracking link and delivery progress back to the shop order. | | Cancellation | Cancelling the shop order cancels the delivery while that is still possible. A cancelled delivery never cancels or refunds the shop order. | ## AI assistants - [ChatGPT](https://maxmove.com/en/developers/docs/integrations/chatgpt): Quote, book, and track transports in ChatGPT. - [Claude](https://maxmove.com/en/developers/docs/integrations/claude): Quote, book, and track transports in Claude. Both run on the [Maxmove MCP server](https://maxmove.com/en/developers/docs/ai/mcp-server) and act as the signed-in Maxmove user. ## Document archives - [DocuWare](https://maxmove.com/en/developers/docs/integrations/docuware): File proof of delivery from internal fleet orders in DocuWare. ## When to use the API Build on the [Maxmove API](https://maxmove.com/en/developers/docs/quickstart) when: - your shop system has no Maxmove integration, - you want to book deliveries from an ERP, TMS, warehouse system, or your own app, - you run a fleet and want your customers' systems to push orders onto your dispatch board ([fleet order ingestion](https://maxmove.com/en/developers/docs/fleet/order-ingestion)). For questions about using Maxmove itself, see the [help center](https://maxmove.com/en/help). --- Source: https://maxmove.com/en/developers/docs/integrations --- # Shopify integration > How the Maxmove Shopify app connects a store, prices same-day delivery at checkout, imports paid orders, and writes fulfillment status back to Shopify. The Maxmove app for Shopify shows Maxmove same-day delivery as a shipping rate at checkout, turns paid orders with that rate into Maxmove deliveries, and reports delivery progress back to the Shopify order. For click-by-click setup, see [Set up the Maxmove Shopify app](https://maxmove.com/en/help/business-api/shopify-maxmove-setup). ## How it connects The app is an embedded Shopify Admin app, listed in the Shopify App Store at [apps.shopify.com/maxmove](https://apps.shopify.com/maxmove). - **Not the public API.** The app talks to Maxmove through its own server-to-server interface, not the `/v1` API, and doesn't use `/v1` API keys. Its orders send no `/v1` webhook events. - **Linking.** From the app, an owner or admin of a Maxmove business workspace signs in to Maxmove and approves the link. One Shopify store is linked to one workspace. - **Shopify access.** The app requests access to locations, orders, products, inventory, fulfillment orders, shipping, and fulfillments, so it can register the shipping rate, read orders, and create fulfillments. - **Shopify webhooks.** Maxmove receives `app/uninstalled`, `orders/create`, `orders/updated`, `orders/cancelled`, and Shopify's privacy compliance topics, and verifies their HMAC signatures. ## Checkout rates Maxmove registers a carrier service called **Maxmove Same-Day Delivery**. The merchant adds it to a shipping zone in Shopify. - **Price.** Maxmove prices the cart live. The shopper sees the price from the store's pricing policy: the live price with a markup or discount, a fixed price, free delivery above a cart subtotal, and an optional cap on the carrier cost. - **Pickup times.** Depending on the policy, the shopper picks the earliest pickup or a 2-hour slot, up to 35 hours ahead. - **Vehicle.** A default vehicle type per pickup location, or an automatic choice based on package dimensions and weights from product variant metafields. - **Failures.** Maxmove answers within 2.5 seconds. If it can't price the cart, it returns no rate and Shopify shows the store's other rates. Carrier-calculated rates from apps need a Shopify plan that supports them: Grow with yearly billing, Advanced, Plus, or a development store. Letting shoppers choose extra services at checkout needs Shopify Plus. ## Data flow **1. Order placed** Shopify sends `orders/create`. Maxmove stores the webhook and processes it in the background, with retries. **2. Import checks** With auto-import on, Maxmove imports the order by itself. With auto-import off, it imports nothing until the merchant sends the order with **Send to Maxmove** on the app's **Orders** page. Either way, the order must use a Maxmove shipping rate, have one shipping line, one open fulfillment order, and one origin location, and be paid (by default, Maxmove waits until the order is paid). Maxmove re-prices the route and checks the result against what the shopper accepted. **3. Delivery created** One Shopify order becomes exactly one Maxmove delivery, even if it is sent more than once. A background reconciliation job catches missed webhooks. With auto-import on, it imports orders whose webhook was missed. With auto-import off, it only applies missed updates and cancellations to orders that were already sent. ## Status sync Once a courier accepts the delivery, Maxmove creates a Shopify fulfillment with the tracking company **Maxmove**, the Maxmove order id as tracking number, and a tracking link. For live orders, Shopify notifies the customer. Maxmove then adds fulfillment events as the delivery progresses: confirmed, in transit, picked up by carrier, out for delivery, and delivered (with a proof-of-delivery summary). The estimated delivery time comes from the courier's ETA. A cancelled delivery adds a failure event. ## Payment The shopper pays the Shopify store. Maxmove charges the workspace's company card per delivery: it authorizes the amount without the merchant present when it imports the order, and captures it after completion. A cancellation releases the authorization. ## Cancellations - Cancelling the order in Shopify cancels the Maxmove delivery while that is still possible, whether auto-import is on or off. - Orders that are no longer paid or change materially are cancelled too. - A delivery cancelled by Maxmove adds a failure event to the fulfillment. It never cancels or refunds the Shopify order. ## Limits and requirements - A Maxmove business workspace with the Integrations and Payments modules, and a company card ready for billing. - At least one active pickup location with a default vehicle type. - A currency with two decimals that matches the quote. - Orders with more than one shipping line, fulfillment order, or origin location are not imported. ## Uninstall and privacy Uninstalling the app deletes its Shopify tokens, deactivates the carrier service, and turns auto-import off. Maxmove processes Shopify's customer and shop redaction requests and keeps raw webhook events for 30 days. --- Source: https://maxmove.com/en/developers/docs/integrations/shopify --- # WooCommerce integration > How the Maxmove WooCommerce plugin connects a store, quotes delivery at checkout, syncs orders to Maxmove, and receives signed status webhooks. The Maxmove Delivery for WooCommerce plugin adds Maxmove as a shipping method, quotes delivery at checkout, creates a Maxmove delivery from each qualifying order, and writes delivery status and the tracking link back to the order. For click-by-click setup, see [Set up the Maxmove WooCommerce plugin](https://maxmove.com/en/help/business-api/woocommerce-maxmove-setup). ## Plugin facts | Property | Value | | --- | --- | | Plugin | Maxmove Delivery for WooCommerce, slug `maxmove-for-woocommerce`, GPLv2 or later | | Requires | WordPress 6.7, PHP 7.4, WooCommerce 8.9 or later | | Compatible with | High-Performance Order Storage, block checkout, classic checkout | | Shipping method id | `maxmove_woocommerce`, enabled per shipping zone | | Distribution | ZIP package provided by Maxmove | ## How it connects The plugin does not use the public `/v1` API or `/v1` API keys. It talks to a dedicated WooCommerce API at `https://api.maxmove.com` (the address is fixed), and its orders send no `/v1` webhook events. **1. Link token** An admin of a Maxmove business workspace creates a single-use link token in the dashboard under **Integrations → WooCommerce**. It is valid for 15 minutes and approved for the store address saved there. **2. Connect** The merchant pastes the token in **WooCommerce → Maxmove**. The plugin sends it to Maxmove with the store URL and its callback URL, `/wp-json/maxmove/v1/webhooks`. Store and callback must be on the approved host, and the callback must be publicly reachable. **3. Plugin credentials** Maxmove returns credentials that the plugin sends as a bearer token from then on. They are renewed while in use, and the plugin checks the connection daily. If Maxmove rejects them, the plugin deletes its local credentials and the store must reconnect. One store connects to one business workspace, and each workspace has one active WooCommerce connection. ## Checkout When the shopper's address matches a zone with the Maxmove method enabled, the plugin requests a quote with: - the store's pickup address and the delivery address, - the cart currency and merchandise subtotal, - up to 4 pickup times: the earliest possible and 2-hour slots within the store's opening hours, - the language (English or German; other WordPress languages fall back to English). The plugin doesn't send line items, weights, or dimensions. Maxmove geocodes the addresses. The shopper sees the price from the store's pricing policy (live price with an adjustment, fixed price, free delivery above a subtotal, and a cap on the carrier cost), not necessarily the carrier price. Each rate is signed, so the order can later be checked against what the shopper accepted. Maxmove hides the method, with a generic notice, when it can't quote: for example when the cart has more than one shipping package, the currency doesn't have exactly two decimals, no pickup time is available, the API doesn't answer in time, or the workspace's billing isn't ready. ## Data flow **1. Order status changes** When an order with a Maxmove shipping line reaches one of the ready statuses (by default `processing` and `completed`), and, if required, is paid, the plugin queues it in Action Scheduler. **2. Order sent to Maxmove** The plugin sends the order in the background and retries network errors, `429`, and `5xx` answers with backoff, honouring `Retry-After`. A reconciler runs every 5 minutes for orders that are still missing, and the merchant can retry from the order with **Retry Maxmove delivery**. **3. Delivery created** Maxmove re-prices the route and rejects the order if the price exceeds what the shopper accepted. One WooCommerce order becomes at most one Maxmove delivery, and each signed rate can only be used for one order. Orders with more than one Maxmove package are blocked. ## Status sync Maxmove pushes status to the plugin's callback URL; the plugin doesn't poll. The webhook request carries these headers: | Header | Content | | --- | --- | | `x-maxmove-event-id` | Event id. The plugin deduplicates on it. | | `x-maxmove-delivery-id` | Delivery id. | | `x-maxmove-event-type` | Event type. | | `x-maxmove-event-timestamp` | Send time. Requests more than 300 seconds off are rejected. | | `x-maxmove-signature` | HMAC-SHA256 signature. | These are not the `/v1` webhook events and use their own event names and signature. The plugin stores the delivery stage and tracking URL on the order and shows the status and tracking link in **My Account** and in customer emails. It does not change the WooCommerce order status by default; a theme or plugin can map events to order statuses with the `maxmove_woocommerce_order_status_for_event` filter. ## Payment The shopper pays the store. Maxmove charges the business workspace's company card per delivery: it authorizes the amount when it imports the order and captures it after completion. If billing isn't ready, the order waits in Maxmove until the merchant completes billing setup. ## Cancellations - An order set to `cancelled` or `refunded` in WooCommerce cancels the Maxmove delivery. If the courier already picked up the goods, the cancellation is rejected and the plugin marks the order for manual action. - A delivery cancelled by Maxmove doesn't cancel or refund the WooCommerce order. - Partial refunds are not synced. ## Settings In WordPress, the merchant sets the link token, the pickup address, the ready order statuses, and per zone whether the method is enabled and its title. Pricing, automatic import, paid-order requirement, vehicle type, extra services, and scheduling are managed in the Maxmove dashboard and shown read-only in the plugin. ## Data stored by the plugin The plugin keeps its settings and credentials in the `maxmove_woocommerce_settings` option, `_maxmove_*` order meta, a webhook ledger table (30 days, for deduplication), and a privacy job table. Deactivating the plugin disconnects the store and deletes the credentials. Uninstalling it removes its tables and options; existing `_maxmove_*` order meta stays. --- Source: https://maxmove.com/en/developers/docs/integrations/woocommerce --- # Shopware integration > How the Maxmove Shopware plugin and the Shopware Cloud app connect a shop, quote delivery at checkout, sync orders, and report delivery status back. Maxmove integrates with Shopware 6.7 in two distributions that share one Maxmove backend: | | Maxmove Delivery (plugin) | Maxmove Delivery Cloud (app) | | --- | --- | --- | | For | Self-hosted and PaaS Shopware | Shopware Cloud | | Type | PHP plugin, technical name `MaxmoveDelivery`, Composer package `maxmove/shopware-delivery` | Shopware App System app, `MaxmoveDeliveryCloud` | | Requires | PHP 8.2, Shopware 6.7 (below 6.8) | Shopware 6.7 | | Checkout price | Live price through the storefront pricing policy | Merchant price only: fixed or free delivery with a cost ceiling | | Pickup choice | Earliest pickup and up to 4 delivery windows | One "Earliest" option | For click-by-click setup of the plugin, see [Set up the Maxmove Shopware plugin](https://maxmove.com/en/help/business-api/shopware-maxmove-setup). ## How it connects Neither distribution uses the public `/v1` API or `/v1` API keys. Both talk to a dedicated Shopware API at Maxmove, and their orders send no `/v1` webhook events. A shop can connect only one of the two. ### Plugin **1. Connection code** An admin of a Maxmove business workspace gets a single-use connection code that is valid for 15 minutes. **2. Connect** The merchant enters the code in the plugin. The plugin creates its own shop id, plugin token, and callback secret and registers them with Maxmove, together with a callback URL on the shop's origin. In production, the shop must use HTTPS. **3. Activate** The connection becomes active after the first **Save and activate** in the plugin's settings. From then on, the plugin authenticates with its plugin token as a bearer token. Install it from the ZIP or with `bin/console plugin:install --activate MaxmoveDelivery`. ### Cloud app The app registers through the standard Shopware app handshake. Its admin pages load from `connect.maxmove.com` inside the Shopware Administration. The connection stays pending until the merchant links it to a Maxmove business workspace. Requests from Shopware to Maxmove are signed with the shop secret (`shopware-shop-signature`). ## Checkout **Plugin.** Maxmove quotes only after the shopper selects the Maxmove shipping method (technical name `maxmove_delivery`). It computes the delivery windows: the earliest pickup plus 2-hour slots within the store's opening hours and lead time, today for same-day and today and tomorrow for scheduled delivery, at most 4. Each window is a signed quote, and the shopper picks one on the confirm page. Headless storefronts can use the plugin's Store API routes. The plugin sends no coordinates, weights, or dimensions; Maxmove geocodes the addresses. The plugin waits up to 3 seconds. Without a fresh quote, checkout is blocked with "Maxmove is currently unavailable. Choose another shipping method." Rule Builder rules on the shipping method are checked by Maxmove at quote time. The shopper price comes from the pricing policy: live price with a percentage adjustment, fixed price, free delivery, or free delivery above a subtotal, with a maximum carrier cost. Fixed and free prices require a maximum carrier cost. **Cloud app.** The app offers one "Earliest" option through the Shopware checkout gateway at the merchant's fixed or free price, with a cost ceiling (30.00 EUR by default). If Maxmove can't quote, the method is removed from checkout. ## Data flow **1. Order event** **Plugin:** on order placement and payment changes, the plugin writes an event to a durable local queue that Shopware's message consumer and a scheduled task drain every 60 seconds, with retries. **Cloud app:** Shopware sends `order.written` and paid-transaction webhooks. **2. Import** By default, Maxmove creates the delivery after payment. It re-quotes the route: the plugin's signed quote can be used for one order only, and the Cloud app requires the quoted price to match the order's shipping total. **3. Delivery created** One Shopware order becomes exactly one Maxmove delivery. ## Status sync Maxmove reports each status change to the shop, in order per order, with up to 20 attempts and a backoff of up to 6 hours between them. The plugin receives signed callbacks; the Cloud app's shop is updated through the Shopware Admin API. Callbacks to the plugin are signed in the `x-maxmove-signature` header: a hex HMAC-SHA256 of `{timestamp}.{body}` with the callback secret, valid for 300 seconds. These are not the `/v1` webhook events. - The tracking URL is written to the order delivery's tracking codes. - The order delivery moves to **shipped** when the courier accepts, picks up, or completes the delivery. The Cloud app skips the transition on acceptance. - A cancelled delivery moves the order delivery to **cancelled**. - The plugin also stores the delivery status in the order's custom field `maxmove_delivery_status`. The merchant setting **Sync tracking** turns all of this on or off and is on by default. When it's off, Maxmove writes no tracking URL, delivery state, or status field into the Shopware order. Each order keeps the setting that applied when Maxmove quoted its delivery, so a change affects new orders, not deliveries already underway. ## Payment The shopper pays the shop. Maxmove charges the business workspace's company card per delivery: it authorizes the amount when it imports the order, captures it after completion, and releases it if the delivery is cancelled before completion. If billing isn't ready, the order waits in Maxmove with a link to the billing setup in the dashboard. ## Cancellations - Cancelling the order or its delivery in Shopware cancels the Maxmove delivery, unless the merchant turned off cancelling on source cancellation (on by default). - With **Sync tracking** on, a delivery cancelled by Maxmove cancels the Shopware order delivery, not the order. Refunds are not synced. ## Limits and requirements - A Maxmove business workspace with the Integrations and Payments modules, a company card ready for billing, a pickup location, and saved settings. - Orders need a customer name, an email address, and a delivery phone number. - A currency with two decimals. - For the plugin, Shopware's message consumer and scheduled tasks must run. - Extra services: stairs, fragile handling, pickup window, two-person delivery, and installation, either paid by the shopper and shown at checkout, or paid by the merchant and hidden. --- Source: https://maxmove.com/en/developers/docs/integrations/shopware --- # ChatGPT integration > How the Maxmove app in ChatGPT connects to a Maxmove account over MCP and OAuth, what it can do per role, and how bookings and payment work. The Maxmove app for ChatGPT lets a signed-in Maxmove user check serviceability, compare vehicle types, get binding quotes, book after explicit confirmation, and track orders in a ChatGPT conversation. For setup, see [Use Maxmove in ChatGPT](https://maxmove.com/en/help/business-api/chatgpt-maxmove-setup). ## How it connects ChatGPT connects to the [Maxmove MCP server](https://maxmove.com/en/developers/docs/ai/mcp-server) at `https://api.maxmove.com/mcp`. It does not use the public `/v1` API or API keys. - **Sign-in.** The user selects **Connect** in ChatGPT and signs in with their Maxmove account (OAuth 2.1 with PKCE). ChatGPT identifies itself with an OAuth client metadata document on a `chatgpt.com` or `openai.com` domain. - **Permissions.** The connection starts with read access. The first booking, cancellation, or dispatch change asks the user to grant write access. - **Workspace.** The connection is bound to the Maxmove workspace that was active when the user connected. To switch, the user changes the workspace in Maxmove and reconnects. - **Disconnect.** Removing the app or its connection in ChatGPT under **Settings → Apps & Connectors** revokes ChatGPT's access. ## Data flow **1. Quote** The user describes the transport. ChatGPT calls the quote tool with the addresses as the user gave them; Maxmove geocodes them and checks serviceability and the vehicle. The quote renders as a booking summary card. **2. Confirm** ChatGPT books only after the user confirms the reviewed quote in the conversation or taps the button on the card. The booking redeems exactly that quote. **3. Track** The order renders as a tracking card with status, driver, and arrival time. The user can list their orders, cancel one, or open a support conversation. Orders booked this way are normal Maxmove orders in the workspace, marked as booked through ChatGPT. They don't send `/v1` webhook events. ## What it can do The tools depend on the workspace type and the user's role: personal and business accounts can quote, book, track, and cancel; fleet control roles can inspect the order board and vehicle readiness and make confirmed dispatch changes; drivers can read their own routes, orders, and tours. See [Tools by role](https://maxmove.com/en/developers/docs/ai/mcp-server#content-tools-by-role). ## Payment Bookings confirmed in ChatGPT use monthly invoice or cash payment. For card payment, ChatGPT hands the reviewed route, time, vehicle, and extras to a booking page on maxmove.com, where the user pays by card. That page prices the route again. ## Requirements - A Maxmove account with an active personal, business, fleet, or driver workspace and an eligible role. - A ChatGPT plan and region in which OpenAI offers apps. - Addresses inside the Maxmove [service area](https://maxmove.com/en/developers/docs/coverage). --- Source: https://maxmove.com/en/developers/docs/integrations/chatgpt --- # Claude integration > How the Maxmove connector in Claude connects to a Maxmove account over MCP and OAuth, what it can do per role, and how bookings and payment work. The Maxmove connector for Claude lets a signed-in Maxmove user check serviceability, compare vehicle types, get binding quotes, book after explicit confirmation, and track orders in a Claude conversation. For setup, see [Use Maxmove in Claude](https://maxmove.com/en/help/business-api/claude-maxmove-setup). ## How it connects Claude connects to the [Maxmove MCP server](https://maxmove.com/en/developers/docs/ai/mcp-server) at `https://api.maxmove.com/mcp` over the Model Context Protocol. It does not use the public `/v1` API or API keys. - **Sign-in.** The user opens **Customize → Connectors** in Claude, selects Maxmove, and signs in with their Maxmove account (OAuth 2.1 with PKCE). Claude identifies itself with an OAuth client metadata document on a `claude.ai` domain. - **Permissions.** The connection starts with read access. The first booking, cancellation, or dispatch change asks the user to grant write access. - **Workspace.** The connection is bound to the Maxmove workspace that was active when the user connected. To switch, the user changes the workspace in Maxmove and reconnects. - **Disconnect.** Disconnecting Maxmove under **Customize → Connectors** revokes Claude's access. ## Data flow **1. Quote** The user describes the transport. Claude calls the quote tool with the addresses as the user gave them; Maxmove geocodes them and checks serviceability and the vehicle. Where Claude supports MCP Apps, the quote renders as a booking summary card. **2. Confirm** Claude books only after the user confirms the reviewed quote in the conversation or taps the button on the card. The booking redeems exactly that quote. **3. Track** The user can follow an order's status, driver, and arrival time, list their orders, cancel one, or open a support conversation. Orders booked this way are normal Maxmove orders in the workspace, marked as booked through Claude. They don't send `/v1` webhook events. ## What it can do The tools depend on the workspace type and the user's role: personal and business accounts can quote, book, track, and cancel; fleet control roles can inspect the order board and vehicle readiness and make confirmed dispatch changes; drivers can read their own routes, orders, and tours. See [Tools by role](https://maxmove.com/en/developers/docs/ai/mcp-server#content-tools-by-role). ## Payment Bookings confirmed in Claude use monthly invoice or cash payment. For card payment, Claude hands the reviewed route, time, vehicle, and extras to a booking page on maxmove.com, where the user pays by card. That page prices the route again. ## Requirements - A Maxmove account with an active personal, business, fleet, or driver workspace and an eligible role. - A Claude plan and region in which Anthropic offers connectors. - Addresses inside the Maxmove [service area](https://maxmove.com/en/developers/docs/coverage). --- Source: https://maxmove.com/en/developers/docs/integrations/claude --- # Maxmove MCP server > How the Maxmove MCP server lets ChatGPT and Claude check serviceability, quote, book, and track transports for a signed-in Maxmove user. Maxmove runs a [Model Context Protocol](https://modelcontextprotocol.io) server that AI assistants use to work with a Maxmove account: check whether an address pair is in the service area, compare vehicle types, get binding quotes, book after explicit confirmation, and track orders. It powers the Maxmove app in ChatGPT and the Maxmove connector in Claude. Setting those up needs no code: - [ChatGPT](https://maxmove.com/en/developers/docs/integrations/chatgpt): How the Maxmove app in ChatGPT connects and what it can do. - [Claude](https://maxmove.com/en/developers/docs/integrations/claude): How the Maxmove connector in Claude connects and what it can do. ## MCP server or REST API? | | MCP server | REST API | | --- | --- | --- | | Acts as | A signed-in Maxmove user, in one workspace | Your workspace, through an API key | | Used by | ChatGPT and Claude | Your own software | | Bookings | Only after the user confirms in the conversation | When your software sends the request | | Authentication | OAuth sign-in with the Maxmove account | `x-api-key` header | The MCP server does not use the public `/v1` API or API keys. To integrate Maxmove into your own software, use the [REST API](https://maxmove.com/en/developers/docs/quickstart). ## How it works - **Endpoint.** `https://api.maxmove.com/mcp`, using the Streamable HTTP transport. - **Sign-in.** OAuth 2.1 with PKCE. The server publishes its protected resource metadata at `https://api.maxmove.com/.well-known/oauth-protected-resource/mcp`; the authorization server is `https://api.maxmove.com/auth`. - **Supported clients.** ChatGPT and Claude. They identify themselves with an OAuth client metadata document, which the server accepts only from ChatGPT and Claude domains. - **Read first, write on demand.** A new connection gets read access (`mcp:access`). The first consequential action, such as creating a booking or cancelling an order, asks the user to grant write access (`mcp:write`). - **One workspace.** The grant is bound to the workspace that is active when the user connects. A later workspace switch in Maxmove doesn't change it; to use another workspace, the user switches it in Maxmove and reconnects. - **Tools follow the role.** The tool list is filtered by the workspace type and the user's role, so the assistant only sees tools the user may use. ## Tools by role | Role | What the assistant can do | | --- | --- | | Personal and business accounts | Check serviceability, list vehicle types, quote, review and confirm bookings, list and track orders, cancel orders, create a tracking share link, and open a support conversation. | | Fleet control roles (owner, admin, fleet manager, dispatcher) | Inspect the order board, order details, vehicles and vehicle readiness, and make confirmed changes: move or reschedule internal orders, claim or release marketplace orders, and cancel orders. | | Drivers | Read their own route, orders, and tours, acknowledge released tour revisions, and go off duty. Navigation, proof of delivery, package scans, and going online stay in the Maxmove Driver app. | Every tool that books, cancels, or changes dispatch state needs the user's explicit confirmation in the conversation before it runs. Quotes need no confirmation because they change nothing. ## Bookings and payment Quotes are binding server quotes with an expiry time. A quote never creates an order, reserves capacity, or charges anyone. The assistant creates a booking only after it has shown the reviewed quote and the user has confirmed it. The booking uses exactly the reviewed quote; changed inputs or an expired quote are rejected. Bookings confirmed in an assistant currently support monthly invoice or cash payment. For card payment, the assistant hands the reviewed route, time, vehicle, and extras to a booking page on maxmove.com, where the user pays by card. That page prices the route again. ## Cards in the conversation Quotes, orders, and tracking render as interactive cards in hosts that support MCP Apps: a booking summary, an order list, and an order tracking card. Booking from the summary card still requires an explicit tap by the user. --- Source: https://maxmove.com/en/developers/docs/ai/mcp-server --- # DocuWare integration > How Maxmove files proof of delivery from internal fleet orders in a DocuWare file cabinet: OAuth connection, index fields, document modes, retries, and replay. The DocuWare integration files proof of delivery (POD) from a fleet's internal orders in a DocuWare file cabinet. It is one of several POD destinations, next to signed webhooks, cloud storage (S3, GCS, Azure Blob), and Snowflake. For the DocuWare app registration and the setup steps, see [Connect DocuWare for proof of delivery](https://maxmove.com/en/help/delivery-fleet/fleet-docuware-app-registration) and [POD destinations and integrations](https://maxmove.com/en/help/delivery-fleet/fleet-pod-destinations). ## Scope - **Fleet workspaces only.** Owners, admins, fleet managers, and dispatchers manage destinations. The fleet plan must include proof of delivery. - **Internal orders only.** The trigger is the internal event `order.pod.uploaded.v1`, sent when a driver uploads POD on an internal fleet order. - **Not the public API.** Deliveries booked through `/v1/deliveries` report proof with the [`delivery.pod_submitted`](https://maxmove.com/en/developers/docs/webhooks/events) webhook and [`GET /v1/deliveries/{deliveryId}/proof-of-delivery`](https://api.maxmove.com/v1/docs#tag/deliveries/GET/v1/deliveries/{deliveryId}/proof-of-delivery) instead. - **Web dashboard.** DocuWare destinations are set up in the web dashboard under **Fleet → Orders → POD destinations**, not in the desktop app. ## How it connects Maxmove signs in to DocuWare with the OAuth 2.0 authorization code flow and a refresh token, as a server-side web application. The redirect URL is `https://api.maxmove.com/api/pod/docuware/callback`, and the default scope is `docuware.platform dwprofile openid offline_access`. During the pilot, the DocuWare administrator creates a custom app registration and enters its client ID and secret in Maxmove. The app registration must allow refresh tokens. DocuWare Cloud and on-premises servers use the same Platform REST API. The server must be reachable from the internet; private addresses and `localhost` are rejected. ## Data flow **1. Driver uploads POD** The driver uploads photos, signatures, signed delivery notes, or documents at pickup or dropoff of an internal order. **2. Destination picks it up** Maxmove matches the event against each active destination's filters: checkpoints (pickup, dropoff) and file kinds (photo, signature, signed delivery note, document). **3. Filed in DocuWare** Maxmove uploads the original files, without ZIP or summary PDF, to the configured file cabinet through its store dialog and fills the index fields. ### Document modes | Mode | Result | | --- | --- | | `per_pod` (default) | One DocuWare document per POD upload. | | `per_order` | One document per order. Later uploads are appended to it. | ### Index fields By default, Maxmove fills these fields when they exist in the store dialog: | Field | Content | | --- | --- | | `MM_REFERENCE` | The order's internal reference, or its order reference or id when it has none. | | `MM_ORDER_REFERENCE` | The order reference. | | `MM_ORG_ID` | The workspace id. | | `MM_ORDER_ID` | The Maxmove order id. | | `MM_POD_ID` | The id of the POD upload. | | `MM_CHECKPOINT` | Pickup or dropoff. | | `MM_WAYPOINT_ID` | The waypoint id. | | `MM_OCCURRED_AT` | When the POD was uploaded. | | `MM_SOURCE` | Where it was uploaded: `driver_app`, `dashboard`, or `public_link`. | In `per_order` mode, only the first four are used. You can map other fields yourself; the dashboard maps visible, writable `MM_*` fields automatically. If the store dialog has a required field that is not mapped, filing fails permanently. ## Retries and replay - Maxmove retries failed uploads with exponential backoff and jitter. The number of attempts is set per destination (1 to 20, default 5). - DocuWare answers in the `4xx` range are not retried, except `408`, `425`, and `429`. - Events that still fail are kept as failed. Replay them from the dashboard, by event or by time range, for example after you fixed the store dialog. - The **Test** button files a one-page test PDF to check the connection. ## Requirements - A fleet workspace whose plan includes proof of delivery. Without it, POD destinations can't be set up. - A DocuWare organization administrator to create the app registration. - A DocuWare server reachable from the internet, a file cabinet, and a store dialog. --- Source: https://maxmove.com/en/developers/docs/integrations/docuware --- # API reference > Base URL, authentication headers, security schemes, and links to every endpoint group of the Maxmove API v1 reference. The API reference is generated from the Maxmove API code and lists every endpoint of the Maxmove API v1 with its fields, responses, and error codes. It also lists the payload of each webhook event. Open it at [api.maxmove.com/v1/docs](https://api.maxmove.com/v1/docs). ## Base URL ```text https://api.maxmove.com/v1 ``` Live and test keys use the same base URL. All paths in these docs are written as `/v1/...`. ## Authentication Send your API key with every request. The reference lists two security schemes for the same key; use either one: | Scheme | Header | | --- | --- | | `partnerApiKey` | `x-api-key: mm_live_…` or `x-api-key: mm_test_…` | | `partnerBearer` | `Authorization: Bearer mm_live_…` | Create keys in the [dashboard](https://dashboard.maxmove.com) under **Settings → API keys**. See [Authentication](https://maxmove.com/en/developers/docs/authentication). ## Endpoint groups | Group | What it covers | | --- | --- | | [Vehicle types](https://api.maxmove.com/v1/docs#tag/vehicle-types) | Vehicle types you can book. | | [Quotes](https://api.maxmove.com/v1/docs#tag/quotes) | Price a route before booking. | | [Deliveries](https://api.maxmove.com/v1/docs#tag/deliveries) | Create, list, track, and cancel deliveries, and fetch proof of delivery. | | [Webhooks](https://api.maxmove.com/v1/docs#tag/webhooks) | Register and manage the endpoints that receive events. | | [Fleet orders](https://api.maxmove.com/v1/docs#tag/fleet-orders) | For fleets: push your customers' orders onto your dispatch board. | | [Webhook events](https://api.maxmove.com/v1/docs#tag/webhook-events) | The requests Maxmove sends to your webhook endpoints, one per event type. | ## Endpoints | Operation | Endpoint | | --- | --- | | [List vehicle types](https://api.maxmove.com/v1/docs#tag/vehicle-types/GET/v1/vehicle-types) | `GET /v1/vehicle-types` | | [Create a quote](https://api.maxmove.com/v1/docs#tag/quotes/POST/v1/quotes) | `POST /v1/quotes` | | [Create a delivery](https://api.maxmove.com/v1/docs#tag/deliveries/POST/v1/deliveries) | `POST /v1/deliveries` | | [List deliveries](https://api.maxmove.com/v1/docs#tag/deliveries/GET/v1/deliveries) | `GET /v1/deliveries` | | [Get a delivery](https://api.maxmove.com/v1/docs#tag/deliveries/GET/v1/deliveries/{deliveryId}) | `GET /v1/deliveries/{deliveryId}` | | [Cancel a delivery](https://api.maxmove.com/v1/docs#tag/deliveries/POST/v1/deliveries/{deliveryId}/cancel) | `POST /v1/deliveries/{deliveryId}/cancel` | | [Get live tracking](https://api.maxmove.com/v1/docs#tag/deliveries/GET/v1/deliveries/{deliveryId}/tracking) | `GET /v1/deliveries/{deliveryId}/tracking` | | [Get proof of delivery](https://api.maxmove.com/v1/docs#tag/deliveries/GET/v1/deliveries/{deliveryId}/proof-of-delivery) | `GET /v1/deliveries/{deliveryId}/proof-of-delivery` | | [Create a webhook endpoint](https://api.maxmove.com/v1/docs#tag/webhooks/POST/v1/webhooks) | `POST /v1/webhooks` | | [List webhook endpoints](https://api.maxmove.com/v1/docs#tag/webhooks/GET/v1/webhooks) | `GET /v1/webhooks` | | [Update a webhook endpoint](https://api.maxmove.com/v1/docs#tag/webhooks/PATCH/v1/webhooks/{webhookId}) | `PATCH /v1/webhooks/{webhookId}` | | [Delete a webhook endpoint](https://api.maxmove.com/v1/docs#tag/webhooks/DELETE/v1/webhooks/{webhookId}) | `DELETE /v1/webhooks/{webhookId}` | | [Send a test event](https://api.maxmove.com/v1/docs#tag/webhooks/POST/v1/webhooks/{webhookId}/test) | `POST /v1/webhooks/{webhookId}/test` | | [Retry failed events](https://api.maxmove.com/v1/docs#tag/webhooks/POST/v1/webhooks/{webhookId}/replay) | `POST /v1/webhooks/{webhookId}/replay` | | [Create a fleet order](https://api.maxmove.com/v1/docs#tag/fleet-orders/POST/v1/fleet/orders) | `POST /v1/fleet/orders` | | [Get a fleet order](https://api.maxmove.com/v1/docs#tag/fleet-orders/GET/v1/fleet/orders/{fleetOrderId}) | `GET /v1/fleet/orders/{fleetOrderId}` | ## Try requests The reference can send real requests. Use a test key (`mm_test_…`): test deliveries are never dispatched or billed. > **Warning:** > A live key (`mm_live_…`) creates real, billable deliveries, also when you send the request from the reference. ## OpenAPI document The OpenAPI 3.1 document is published at [`https://api.maxmove.com/v1/openapi.json`](https://api.maxmove.com/v1/openapi.json). Use it to generate a client or to import the API into your HTTP tool. It contains only the public `/v1` endpoints and the webhook payloads. ## Guides The guides explain the workflows behind the endpoints: the [quickstart](https://maxmove.com/en/developers/docs/quickstart), [idempotency](https://maxmove.com/en/developers/docs/concepts/idempotency), [errors](https://maxmove.com/en/developers/docs/concepts/errors), [rate limits](https://maxmove.com/en/developers/docs/concepts/rate-limits), and [webhook signatures](https://maxmove.com/en/developers/docs/webhooks/verify-signatures). --- Source: https://maxmove.com/en/developers/docs/api-reference --- # Use these docs in AI tools > Read the Maxmove developer docs as Markdown, give your AI assistant the full text, or connect the docs MCP server to Cursor, VS Code, or Claude Code. Coding assistants work best with the current documentation in their context. The Maxmove developer docs are available in three machine-readable forms. ## Markdown pages Every docs page is also served as plain Markdown. Add `.md` to the page URL, or request the page with `Accept: text/markdown`: ```bash curl https://maxmove.com/en/developers/docs/quickstart.md ``` On any page, **Copy page** copies the same Markdown, and its menu opens the page in ChatGPT or Claude. ## llms.txt and the full text - [llms.txt](https://maxmove.com/llms.txt) lists every docs page with a one-line description and instructions for AI tools working with the Maxmove API. - [llms-full.txt](https://maxmove.com/llms-full.txt) contains the complete developer docs in one file. ## Docs MCP server The docs MCP server lets an assistant search the docs and read pages on demand. It is public and read-only, and it needs no API key. It can't read or change anything in your Maxmove account. ```text https://maxmove.com/developers/docs/mcp ``` It has two tools: | Tool | What it does | | --- | --- | | `search_maxmove_docs` | Searches the docs and returns matching pages with their Markdown URLs. | | `get_maxmove_doc` | Returns one page as Markdown, by path (`webhooks/verify-signatures`) or URL. | ```bash Claude Code claude mcp add --transport http maxmove-docs https://maxmove.com/developers/docs/mcp ``` ```json Cursor { "mcpServers": { "maxmove-docs": { "url": "https://maxmove.com/developers/docs/mcp" } } } ``` ```json VS Code { "servers": { "maxmove-docs": { "type": "http", "url": "https://maxmove.com/developers/docs/mcp" } } } ``` For Cursor, put the configuration in `.cursor/mcp.json`. For VS Code, put it in `.vscode/mcp.json`. The **Copy page** menu on every page also has **Connect to Cursor** and **Connect to VS Code**. > **Note:** > The docs MCP server is separate from the [Maxmove MCP server](https://maxmove.com/en/developers/docs/ai/mcp-server), which ChatGPT and Claude use to quote and book transports for a signed-in account. --- Source: https://maxmove.com/en/developers/docs/ai-tools --- # Glossary > Short definitions of the terms used in the Maxmove API and these docs, with links to the page that explains each one. ## Deliveries and prices **Delivery.** One booked transport from a pickup to a dropoff, optionally with stops in between. Ids start with `del_`. See [Deliveries and statuses](https://maxmove.com/en/developers/docs/concepts/deliveries). **Quote.** A price for one route and vehicle type, valid for about 5 minutes. Ids start with `qt_`. Booking with its `quote_id` locks the price. See [Quotes and prices](https://maxmove.com/en/developers/docs/concepts/quotes). **Route.** Everything a quote prices: pickup, stops, dropoff, `vehicle_type_id`, `extras`, `items`, and `scheduled_at`. A delivery booked with a quote must send the same route. **Waypoint.** A place on the route: the pickup, a stop, or the dropoff. Each waypoint has an address with coordinates, and on a delivery a contact and optional instructions. **Vehicle type.** The kind of vehicle that carries the delivery, for example `courier`. List them with `GET /v1/vehicle-types`. See [Items and vehicle fit](https://maxmove.com/en/developers/docs/guides/items-and-vehicle-fit). **Items.** Optional list of goods with dimensions and weight. Maxmove uses them to check that the vehicle type can carry them. **Extras.** Optional services such as loading help, priced in the quote's `breakdown`. **Immediate delivery.** A delivery without `scheduled_at`. Maxmove starts looking for a courier right away. **Scheduled delivery.** A delivery with `scheduled_at`, the requested pickup time. See [Scheduled deliveries](https://maxmove.com/en/developers/docs/guides/scheduled-deliveries). **Service area.** The regions where Maxmove operates. Routes outside them answer `400 service_area_unavailable`. See [Coverage](https://maxmove.com/en/developers/docs/coverage). **`amount_cents`.** The total price in cents of `currency`, including VAT. ## Statuses and tracking **Status.** Where a delivery stands: `pending`, `courier_assigned`, `at_pickup`, `picked_up`, `in_transit`, `at_dropoff`, and the final `delivered` or `cancelled`. Statuses only move forward. **Courier.** The driver who picks up and delivers the goods. **Tracking page.** A public page that shows the recipient where the delivery is. Its link is `tracking_url` on the delivery. See [Track deliveries](https://maxmove.com/en/developers/docs/guides/tracking). **ETA.** The expected time until the courier reaches the next waypoint, in `eta_seconds` of the tracking snapshot. **Proof of delivery.** What the courier collects at handover, such as a signature, photos, or the recipient's name. See [Proof of delivery](https://maxmove.com/en/developers/docs/guides/proof-of-delivery). ## Keys and modes **API key.** The secret your server sends in the `x-api-key` header. Keep it on your server and never in a browser or app. See [Authentication](https://maxmove.com/en/developers/docs/authentication). **Live mode.** Requests with a live key (`mm_live_…`) create real deliveries that are dispatched and billed. **Test mode.** Requests with a test key (`mm_test_…`) create simulated deliveries that are never dispatched or billed. See [Test mode](https://maxmove.com/en/developers/docs/test-mode). **Robocourier.** The simulated courier that moves test deliveries through every status. **Permission.** What an API key may do, for example create deliveries or manage webhooks. You choose them when you create the key. **Workspace.** Your business or fleet account in Maxmove. API keys, deliveries, and invoices belong to one workspace. ## Requests and errors **Idempotency key.** The `Idempotency-Key` header that makes a create request safe to retry: a retry returns the original delivery instead of booking a second courier. See [Idempotency](https://maxmove.com/en/developers/docs/concepts/idempotency). **`external_id`.** Your own reference for a delivery, such as your order number. It is unique per mode. **Error code.** The stable `error.code` in every error response, for example `quote_expired`. Branch on the code, not the message. See [Errors](https://maxmove.com/en/developers/docs/concepts/errors). **Request id.** The `request_id` in every error response. Send it when you contact [support](https://maxmove.com/en/developers/docs/support). **Rate limit.** How many requests a key may send per time window. Over the limit, requests answer `429 rate_limited`. See [Rate limits](https://maxmove.com/en/developers/docs/concepts/rate-limits). ## Webhooks **Webhook endpoint.** A URL of yours that receives events. Ids start with `we_`. See [Webhook events](https://maxmove.com/en/developers/docs/webhooks/events). **Event.** One message Maxmove sends to your endpoint when something happens, for example `delivery.status_updated`. Ids start with `evt_`. **Signing secret.** The `whsec_…` secret of an endpoint. Use it to check that an event really comes from Maxmove. See [Verify signatures](https://maxmove.com/en/developers/docs/webhooks/verify-signatures). **Replay.** Sending failed events to an endpoint again. See [Retries and replay](https://maxmove.com/en/developers/docs/webhooks/retries). ## Fleets and integrations **Fleet.** A transport company that runs its own drivers in Maxmove. **Fleet order.** An order a fleet's customer pushes onto the fleet's dispatch board with `POST /v1/fleet/orders`. See [Fleet order ingestion](https://maxmove.com/en/developers/docs/fleet/order-ingestion). **Integration.** A ready-made connection to a shop system, AI assistant, or document archive that needs no code. See [Integrations](https://maxmove.com/en/developers/docs/integrations). **MCP server.** The server that lets AI assistants such as ChatGPT and Claude quote, book, and track transports for a signed-in Maxmove user. See [MCP server](https://maxmove.com/en/developers/docs/ai/mcp-server). --- Source: https://maxmove.com/en/developers/docs/glossary --- # Versioning > How the Maxmove API v1 and its webhook payloads are versioned, which changes can happen without notice, and how to build an integration that keeps working. ## API version The major version is part of the path: every endpoint lives under `/v1`. Breaking changes come as a new major version with a new path. Your integration keeps working on `/v1` until you move it. Within v1, Maxmove only makes additive changes: - new endpoints, - new optional request fields, - new response fields, - new webhook event types, - new error codes. ## Webhook payload version Every webhook event carries a `version` field. The current version is `2026-10`. ```json { "id": "evt_4c1f9e2b7a3d8c5e6f0a1b2c3d4e5f60", "type": "delivery.status_updated", "version": "2026-10", "created_at": "2026-10-07T09:42:11Z", "data": { … } } ``` `version` changes only with breaking payload changes. Additive changes, such as new fields in `data`, keep the version. See [Webhook events](https://maxmove.com/en/developers/docs/webhooks/events). ## Build for additive changes - **Ignore unknown fields** in responses and webhook payloads. - **Handle unknown error codes** by their HTTP status. See [Errors](https://maxmove.com/en/developers/docs/concepts/errors). - **Ignore unknown event types**, or subscribe only to the events you handle with `events` on the webhook endpoint. - **Don't depend on field order** in JSON objects. ## Where changes are announced Changes to the API and webhooks are listed in the [changelog](https://maxmove.com/en/developers/docs/changelog). The [API reference](https://api.maxmove.com/v1/docs) is generated from the API code, so it always shows the current state. --- Source: https://maxmove.com/en/developers/docs/versioning --- # Changelog > Changes to the Maxmove API v1, its webhooks, and the developer documentation, newest first. This page lists changes to the Maxmove API v1, its webhooks, and these docs. Within v1, changes are additive; see [Versioning](https://maxmove.com/en/developers/docs/versioning). ## 2026-10-07 - The developer documentation is now part of maxmove.com at `/developers/docs`. The API reference is generated from the API code and served at [api.maxmove.com/v1/docs](https://api.maxmove.com/v1/docs), with the OpenAPI document at `https://api.maxmove.com/v1/openapi.json`. - API v1 at `https://api.maxmove.com/v1`: vehicle types, quotes, deliveries with tracking, cancellation, and proof of delivery, webhook endpoints, and fleet order ingestion. - Webhook payloads carry `"version": "2026-10"`. - The API reference lists the requests Maxmove sends to webhook endpoints in their own group, **Webhook events**. - Quotes accept `scheduled_at`. A delivery booked with a `quote_id` must use the same `scheduled_at` as its quote. - New error codes: `service_area_unavailable`, `invoice_billing_not_enabled`, `invoice_credit_limit_exceeded`, `invoice_statement_overdue`, and `live_key_required`. See [Errors](https://maxmove.com/en/developers/docs/concepts/errors). - Fleet orders need a live key. Test keys answer `403 live_key_required`. - A delivery for which no courier was found ends as `cancelled` and sends `delivery.cancelled`. --- Source: https://maxmove.com/en/developers/docs/changelog --- # Support > How to reach Maxmove support about the API, webhooks, and integrations, and what to include so your request can be answered quickly. ## Contact | Channel | Use it for | | --- | --- | | Email: [support@maxmove.com](mailto:support@maxmove.com) | API and integration questions, including technical support. | | Chat | Questions while you are on maxmove.com. Open the chat widget on any page, including these docs. | | Phone: [+49 221 95676033](tel:+4922195676033) | Talking to support directly. | | [Contact form](https://maxmove.com/en/contact) | Requests to a specific team, for example support or sales. | For questions about using Maxmove itself, for example the dashboard, apps, or billing, see the [help center](https://maxmove.com/en/help). ## What to include - The `request_id` from the error response. Every error answer carries one. See [Errors](https://maxmove.com/en/developers/docs/concepts/errors). - The delivery id (`del_…`) or webhook endpoint id (`we_…`), and for webhooks the event id (`evt_…`). - Whether you used a live or a test key. Never send the key itself. - The time of the request, with time zone. - For integrations, the shop system and the shop URL. Never send API keys, webhook signing secrets, or OAuth client secrets by email or chat. If a key was exposed, revoke it in **Settings → API keys** and create a new one. --- Source: https://maxmove.com/en/developers/docs/support