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.

Finding a location

Every search takes a location slug, and the slug is ours, not yours. Do not build one from a city name: an unknown slug is not rejected, it is searched. The API first tries it as a slug, then as a spelled-out name, and when neither matches it returns 200 with an empty vehicles list — indistinguishable from a city we serve that has no cars on those dates. The only reliable input is a slug read from the two endpoints below, so resolve locations through them and cache the result; the list changes rarely.

Both need the rentals:quote scope — the one availability needs, so a credential that can search can also resolve a location. Neither is paginated by page number; locations uses the cursor described in Pagination.

GET /v1/rentals/countries lists the countries with at least one published pickup city:

curl -sS "https://{your-base-url}/v1/rentals/countries" \
  -H "Authorization: Bearer $TOKEN"
{
  "rows": [
    { "country_code": "ma", "city_count": 7 },
    { "country_code": "ae", "city_count": 3 }
  ]
}

GET /v1/rentals/locations lists the pickup places themselves. Filter by country (a two-letter code from the call above), by types (a comma-separated subset of city, airport, station, region, country), or by q to match on the name for a type-ahead picker. locale picks the language of name; limit defaults to 25.

curl -sS -G "https://{your-base-url}/v1/rentals/locations" \
  -H "Authorization: Bearer $TOKEN" \
  --data-urlencode "country=ma" \
  --data-urlencode "types=city,airport" \
  --data-urlencode "q=marrak" \
  --data-urlencode "locale=en"
{
  "rows": [
    {
      "id": 2081,
      "slug": "marrakesh",
      "type": "city",
      "country_code": "ma",
      "parent_slug": "morocco",
      "name": "Marrakesh"
    },
    {
      "id": 2114,
      "slug": "marrakesh-menara-airport",
      "type": "airport",
      "country_code": "ma",
      "parent_slug": "marrakesh",
      "name": "Marrakesh Menara Airport"
    }
  ],
  "has_more": false,
  "next_cursor": null
}

slug is the value you pass as location (or pickup_location / delivery_location) to availability. An airport slug is fine: cars belong to cities, and a search for an airport is widened to its city automatically. The reverse also holds — parent_slug walks an airport back to its city if you want to group a picker.

When has_more is true, repeat the call with cursor=<next_cursor> and the same filters. A cursor from one filter set is not valid for another.

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.

For a delivery, send the delivery point as coordinates rather than a city slug: delivery_lat + delivery_lng. Cars are listed against cities, so we snap the point to the nearest city we serve — the same rule our own storefront applies when a customer drops a pin — search that city as a delivery, and tell you what we did:

curl -sS -G "https://{your-base-url}/v1/rentals/availability" \
  -H "Authorization: Bearer $TOKEN" \
  --data-urlencode "delivery_lat=33.5731" \
  --data-urlencode "delivery_lng=-7.5898" \
  --data-urlencode "date_from=2026-11-02" \
  --data-urlencode "date_to=2026-11-06"
{
  "delivery": {
    "lat": 33.5731,
    "lng": -7.5898,
    "city_slug": "casablanca",
    "city_name": "Casablanca",
    "distance_km": 3.2
  },
  "days": 4,
  "vehicles": [ … ]
}

There is no distance cut-off on our side: distance_km is there so you can decide that a pin 80 km from the nearest served city is not a delivery you want to offer. A lone delivery_lat is a 400; a point no served city can be resolved for is 422 delivery_not_available. When you also send delivery_location, the slug wins and no snapping happens.

{
  "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,
  "booking_mode": "variable",
  "use_polling": false,
  "search_id": null,
  "status": null,
  "poll_after_ms": null,
  "expires_at": null
}

Four 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.

booking_mode tells you which pricing contract this answer was produced under, so you never have to infer it from what is missing. Most accounts are variable: the customer never pays less than the quote and pays more only after explicitly approving the higher figure. Our crews do bid against the quote, but a bid below it changes the crew’s share of the booking, not the customer’s price — so a lower bid is never a lower charge, and a higher one is never charged without that approval. An account set to fixed gets a stricter promise — the price you were quoted is the price the customer pays — and, because only crews that have committed to honour their advertised prices can make that promise, a fixed account is shown only those crews. Where a city’s crews have not made that commitment, a fixed account is answered from our wider inventory instead, at prices that are equally firm — through the same search, which may then need a second call (see below). The one case that can still come back empty is a search that inventory cannot answer either, such as a delivery search; the response then carries hint_code: "no_fixed_price_crews" with a human-readable hint, so an empty list is never ambiguous. Per-car GET /v1/rentals/availability?car_slug=… follows the same rule and answers available: false, reason: "unavailable" for a crew outside your mode. You do not set this per request — it is a property of your account; talk to us to change it.

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.

For a delivery ("pickup_type": "delivery"), also send where the car goes: delivery_lat + delivery_lng for the point, and delivery_address for the text the crew reads. Coordinates are stored exactly as you send them; an address alone is a fallback we geocode. The same three fields are accepted by the one-step POST /v1/rentals/bookings. 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.

curl -sS -X DELETE "https://{your-base-url}/v1/rentals/reservations/DC-2026-EWAG94" \
  -H "Authorization: Bearer $TOKEN"

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.

One car

GET /v1/rentals/cars/{slug} reads 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. The slug is the one availability returned for the vehicle. currency and locale are optional and behave as they do on availability.

curl -sS -G "https://{your-base-url}/v1/rentals/cars/dacia-logan-casablanca" \
  -H "Authorization: Bearer $TOKEN" \
  --data-urlencode "currency=EUR"
{
  "id": 4412,
  "slug": "dacia-logan-casablanca",
  "title": "Dacia Logan",
  "location": { "id": 2075, "slug": "casablanca", "name": "Casablanca" },
  "price": 42.5,
  "currency": "EUR",
  "base_currency": "MAD",
  "base_total": 460,
  "fx_rate": 0.0924,
  "native_base": 400,
  "native_currency": "MAD",
  "unit": "day",
  "fleet_size": 3,
  "review_score": 4.6,
  "review_count": 12
}

price is per unit (always day today) in the currency you asked for, and the base_* and fx_rate fields are the same audit trail availability carries. Every key is always present; a value that does not apply is null, never omitted. A slug you did not get from availability — or one belonging to a car that has since been unpublished — is a 404.

The pickup-location list that used to sit here has moved up to Finding a location.

Next