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.
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_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. 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
- Reading and cancelling bookings
- Idempotency — why every creating POST needs a key
- Errors