GuidesCar rentals

Car rentals

coverage → availability → reserve → book → view → cancel

Rentals keep a reserve-then-book split. If you only ever need one call, there is also a one-step POST /v1/rentals/bookings that does both at once — covered at the end.

Every route on this page needs the rentals product enabled on your account, plus rentals:quote to look and rentals:book to buy.

Coverage

GET /v1/coverage populates a country picker across both products in one call.

curl -sS "https://{your-base-url}/v1/coverage" \
  -H "Authorization: Bearer $TOKEN"
{
  "countries": [
    {
      "country_code": "ma",
      "name": "Morocco",
      "products": {
        "car_rentals": { "available": true, "locations": 7 }
      }
    },
    {
      "country_code": "ae",
      "name": "United Arab Emirates",
      "products": {
        "car_rentals": { "available": true, "locations": 3 }
      }
    }
  ]
}

A country missing the car_rentals key is not served for rentals at all — you will see the key absent rather than an available: false.

Coverage is not a supply guarantee. car_rentals.available is true when the country has at least one published pickup city, which is the operator’s own statement of “we serve here”. Whether a particular location on particular dates has a car can only be learned by searching for it. This is deliberate: keying coverage on bookable car count instead would hide every market we serve without holding local inventory ourselves.

GET /v1/rentals/countries answers the same question in a rentals-only shape.

Availability

GET /v1/rentals/availability is the rental search. There is no second one.

curl -sS -G "https://{your-base-url}/v1/rentals/availability" \
  -H "Authorization: Bearer $TOKEN" \
  --data-urlencode "location=casablanca" \
  --data-urlencode "date_from=2026-11-02" \
  --data-urlencode "date_to=2026-11-06" \
  --data-urlencode "age=30"

date_from and date_to are both required in practice, both YYYY-MM-DD, and date_to must be strictly after date_from. (The OpenAPI document marks no query parameter as required on this route, because the handler checks presence itself to return a stable error.code rather than a schema artefact. Treat them as required.) location names where; pickup_location and delivery_location are alternatives that additionally pin the collection mode. Optional filters include age, vehicle_type, pickup_hour, return_hour, pickup_type, price_min, price_max, q, attrs, country, currency, locale, page and limit.

{
  "days": 4,
  "date_from": "2026-11-02",
  "date_to": "2026-11-06",
  "vehicles": [
    {
      "slug": "dacia-logan-casablanca",
      "title": "Dacia Logan",
      "vehicle_type": { "slug": "vehicle-sedan", "name": "Sedan" },
      "image_url": "https://media.drivecars.ai/dacia-logan.jpg",
      "price_per_day": 460,
      "total": 1840,
      "currency": "MAD",
      "base_currency": "MAD",
      "base_total": 1840,
      "fx_rate": 1,
      "variable_pricing": true,
      "location": { "id": 4, "slug": "casablanca", "name": "Casablanca" },
      "fleet_size": 6
    }
  ],
  "total": 1,
  "page": 1,
  "limit": 12,
  "use_polling": false,
  "search_id": null,
  "status": null,
  "poll_after_ms": null,
  "expires_at": null
}

Three things to read carefully.

total is the real dated total for the whole stay, not a per-day figure you multiply. It already reflects duration tiers, date exceptions and any sale price in effect — variable_pricing: true tells you tiers or exceptions moved it. It is the same number a booking will bill, which is exactly why it is what belongs in expected_total when you book. days is reported once at the top level because it is a property of the stay window, identical for every vehicle.

Every vehicle has the same shape and books the same way: pass its slug back as car_slug. There is one request shape and you never branch. Fields that do not apply to a given vehicle (location, fleet_size, vehicle_type) come back null rather than being omitted, so your deserializer sees one schema.

base_currency, base_total and fx_rate are always present, even when nothing was converted (fx_rate: 1). base_total is denominated in the currency your invoice will be, so reconciling a month of bookings against our billing does not require looking up what the rate was on the day — the quote carried it. Ask for a different currency with ?currency=EUR; an unsupported code is 400 unsupported_currency and names the supported set rather than silently pricing in something else.

When a search needs a second call

Most searches are answered completely in that one request, and use_polling: false says so. Some need longer than a single HTTP request allows. When that happens the response comes back immediately with use_polling: true, possibly an empty vehicles array, and a handle:

{
  "vehicles": [],
  "use_polling": true,
  "search_id": "b3f1c8a2-...",
  "status": "pending",
  "poll_after_ms": 1500,
  "expires_at": "2026-11-01T10:42:00.000Z"
}

Poll the same URL with only search_id:

curl -sS -G "https://{your-base-url}/v1/rentals/availability" \
  -H "Authorization: Bearer $TOKEN" \
  --data-urlencode "search_id=b3f1c8a2-..."

Dates and location are not needed on a poll — the handle carries them. Wait poll_after_ms between attempts.

  • Check use_polling on every response, including the first. Assuming a poll loop is always required is the common mistake, and it costs you a round trip on every search that already finished.
  • Keep polling while status is pending. There is no deadline on our side and a search can exceed a minute. A client-side timeout turns a slow success into a false failure.
  • Only expired or failed ends the loop. pending is not an error state. complete is your answer.
  • A search_id belonging to another partner answers 404, for the reason on the Authentication page.

status is one of pending, complete, failed, expired. All four handle fields are present-and-null when the answer is already complete, so the response shape never varies.

Reserve

POST /v1/rentals/reservations creates a draft booking.

curl -sS -X POST "https://{your-base-url}/v1/rentals/reservations" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 4f8c1e02-9b7a-4d3e-8f21-6c0a5d9e3b17" \
  -d '{
    "car_slug": "dacia-logan-casablanca",
    "date_from": "2026-11-02",
    "date_to": "2026-11-06",
    "pickup_hour": 10,
    "return_hour": 10,
    "pickup_type": "pickup",
    "customer": {
      "first_name": "Amina",
      "last_name": "Berrada",
      "email": "amina@example.com",
      "phone": "+212600000000"
    },
    "expected_total": 1840,
    "external_ref": "acme-order-99213"
  }'

car_slug, date_from, date_to and customer (with first_name, last_name and email) are required. units defaults to 1, pickup_type to pickup, payment_method to card. expected_total is optional here — you may not have shown your customer a price yet at reserve time — but it is checked if you send it, and enforced again at book time.

{
  "reservation": {
    "reference": "DC-8F2K91",
    "product": "car_rental",
    "status": "draft",
    "external_ref": "acme-order-99213",
    "starts_at": "2026-11-02T10:00:00.000Z",
    "ends_at": "2026-11-06T10:00:00.000Z",
    "total": 1840,
    "currency": "MAD",
    "partner_commission": 184,
    "net_payable": 1656,
    "customer": {
      "first_name": "Amina",
      "last_name": "Berrada",
      "email": "amina@example.com"
    },
    "created_at": "2026-11-01T09:12:44.000Z"
  },
  "expires_at": "2026-11-01T09:42:44.000Z",
  "note": "A reservation does NOT hold this car. ..."
}

A reservation does not hold the car

This is the single most expensive misreading of this API, so the response says it in note as well as here.

A reservation is a draft booking. Nothing about creating one removes that car from availability — another customer’s search still returns it, and another customer’s booking can still take it, while your reservation exists. book is the separate call that commits money and actually claims the car.

Do not build a UI that tells your customer “we’re holding this car for you” off a 201 from reserve. That is a promise this endpoint cannot keep, and the customer-facing failure lands on you, not on us.

The ~30-minute expires_at is cleanup, not a hold. An abandoned draft does clear itself — but equally, a customer who wanders off and comes back after half an hour may find the reservation gone and the car gone with it.

DELETE /v1/rentals/reservations/{reference} drops a draft nobody has paid for. It refuses anything past draft with a 409 pointing at the booking-cancel route, because those are genuinely different operations: one discards a draft, the other cancels a booking with money attached.

Book

POST /v1/rentals/reservations/{reference}/book turns an owned, unexpired draft into a real booking.

curl -sS -X POST \
  "https://{your-base-url}/v1/rentals/reservations/DC-8F2K91/book" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 7a2b9d41-3c85-4e60-9a1f-2d8e7b04c593" \
  -d '{
    "payment_reference": "pi_3QhK2xLm00001",
    "expected_total": 1840
  }'

payment_reference is required — it is your own PSP’s reference for the charge you already took. You are the merchant of record on this leg; DriveCars never touched that money, and this string is the only handle either side has when reconciling later.

{
  "booking": {
    "reference": "DC-8F2K91",
    "product": "car_rental",
    "status": "pending",
    "total": 1840,
    "currency": "MAD",
    "partner_commission": 184,
    "net_payable": 1656,
    "...": "..."
  },
  "status_note": "This booking is pending, not confirmed. ..."
}

expected_total and price_mismatch

Rentals have no binding quote endpoint. So instead of holding a price for you, we ask you to assert the one you already showed your customer, and refuse if we disagree by more than a cent:

{
  "error": {
    "type": "invalid_request_error",
    "code": "price_mismatch",
    "message": "The reservation total does not match `expected_total`. ...",
    "details": {
      "expected_total": 1840,
      "current_total": 1920,
      "currency": "MAD",
      "reference": "DC-8F2K91"
    }
  },
  "request_id": "req_01JC..."
}

details.current_total is the price we actually computed, in details.currency. Re-quote against it rather than assuming your own number drifted by a rounding error.

That is a 409, and nothing was charged or confirmed. Recovering from it requires a new Idempotency-Key, not a retry of the old one — that key is now permanently associated with this outcome, and replaying it just returns the same 409 forever. Send a fresh key with the corrected expected_total.

expected_total is optional at reserve and at book; it is required on the one-step POST /v1/rentals/bookings, which has no earlier call to have checked it.

A successful booking is not a confirmed booking

status comes back pending, and status_note says why. A DriveCars rental reaches confirmed only when a crew accepts it. A 200 from book means the order is placed with DriveCars and your payment is recorded — never that a car is held.

Some bookings come back pending_confirmation instead, alongside a fulfilment block:

{ "status": "pending_confirmation", "fulfilment": { "state": "pending", "reference": null } }

That means we have taken the order but the underlying rental is not yet secured. fulfilment.state is one of none, pending, reserved, failed, cancelled, and it becomes reserved — with a confirmation reference — only once the rental is actually secured. Read fulfilment.state before telling your customer their car is held.

Reporting that state as confirmed to a partner who is merchant of record, invoiced for the leg, and cannot see our operations queue would be a lie with a bill attached — which is why it gets its own status rather than being folded into confirmed.

Poll GET /v1/bookings/{reference} for the current state, and branch on confirmed alone. More than one not-yet-confirmed status exists, they carry no obligation you need to treat differently, and new ones may be added without notice. Treating “not confirmed” as one bucket is the version of this that keeps working.

One-step booking

POST /v1/rentals/bookings does reserve and book in a single call. Same body as reserve, plus payment_reference and a required expected_total. Use it when your checkout takes payment before it ever needs a DriveCars reference — there is no draft to abandon, so there is nothing to expire.

curl -sS -X POST "https://{your-base-url}/v1/rentals/bookings" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 91b7d3e5-6a04-4f28-b9c1-5e3a7d260f84" \
  -d '{
    "car_slug": "dacia-logan-casablanca",
    "date_from": "2026-11-02",
    "date_to": "2026-11-06",
    "customer": {
      "first_name": "Amina",
      "last_name": "Berrada",
      "email": "amina@example.com"
    },
    "expected_total": 1840,
    "payment_reference": "pi_3QhK2xLm00001"
  }'

The price_mismatch and status rules above apply identically.

Other rental reads

  • GET /v1/rentals/locations — pickup cities, for a location picker.
  • GET /v1/rentals/cars/{slug} — one published car, priced at your own commercial terms. A deliberate subset of a full storefront product page: what a partner’s own listing page needs, not gallery and FAQ content.

Next