Car rentals
coverage → availability → reserve → book → view → cancelRentals 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_pollingon 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
statusispending. 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
expiredorfailedends the loop.pendingis not an error state.completeis your answer. - A
search_idbelonging to another partner answers404, 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
- Reading and cancelling bookings
- Idempotency — why every creating POST needs a key
- Errors