POST /v1/quotes asks what a delivery to a
given address costs and holds that price for you; POST /v1/deliveries sends
the quote back and charges the order the price you were shown.
The quote is optional. Take it when a human is about to see the number and
decide something, skip it when nobody is: a create with no quote_id prices
itself inline at the current price, in one call.
quote_and_create.rb
The quote
A quote sets the price at the moment you ask for it, and holds it for you to bind. It is not a reservation of a courier. Nothing is dispatched, no money moves, and letting one expire costs you nothing. Bind a quote and the order is charged thefee_cents and the split you were
shown. The courier network books the trip when the delivery is created, and the
price it books can differ slightly from the one quoted. We book the very quote
you were shown whenever it is still valid, so this is rare. When it happens, the
difference appears on your ledger as its own requote_adjustment line (negative
when the booked trip cost more, positive when it cost less). It never changes the
fee_cents, the split or the total your customer sees.
You send the store, the dropoff, and enough about the order to price it: the
payment method and the order value both change what a delivery costs, so a
quote taken without them is a quote for a different order. It answers with:
expires_at is at most fifteen minutes out and is often sooner. Two rules
follow from that, and both are refusals rather than surprises:
- Creating against an expired quote returns
400 quote_expired. Ask for a new one and try again; nothing was created. - A quote is single use. Spending it twice returns
400 quote_consumed, which is what a duplicate submit looks like from here.
The create
Required on every create:store_id, external_id, payment_method,
order_value_cents, and a dropoff with at least a name, a phone and a
street. Everything else has a default.
order_value_cents is the value of the food or goods, and it is yours to state.
It is never summed from the manifest, and it never includes the delivery fee.
Idempotency-Key is required
Send an Idempotency-Key header, at most 255 characters, on every create. It is
not optional, and a create without one is refused.
Key it on your order rather than on the attempt. That is what makes the header
worth having: when a create times out you do not know whether a courier was
dispatched, and repeating it with the same key answers with the original
delivery instead of sending a second one. The original response is replayed for
24 hours, status code included.
Reusing a key with a different body returns 409 idempotency_conflict. That
is deliberate: it means two different orders were sent under one key, and
answering with either of them would be wrong.
A repeat that arrives while the first request with that key is still being
processed also returns 409 idempotency_conflict. It created nothing, and it
does not mean the first request failed: that request goes on to its own answer,
and once it has answered, the same request with the same key replays it.
external_id is how you will recognise it
external_id is your own reference for the order. It comes back on every
response and every webhook, it is queryable with
GET /v1/deliveries?external_id=..., and it is unique per key mode, so your
staging system and your production system can both use order-841 without ever
colliding. Sending one you already used in that mode is refused.
You do not have to store Listo’s dlv_... id at all if you would rather not.
Payment methods
On
card the response carries payment.url: the branded link Listo sends your
customer itself, as soon as the order is created (see
what Listo sends your customer). You can show
it on your own screen or keep it, but you do not have to send it, and you should
not: your customer would get the same link twice, from two senders. The delivery
sits there until it is paid, then dispatches on its own. The link disappears
from the object the moment it is paid or cancelled, because a dead link in a
payload is worse than none, and Listo sends nothing for a link that is no longer
live.
On prepaid the whole fee is yours: there is nobody for Listo to collect a
customer share from on an order it never charges the customer of. So the share is
always 100 and business_fee_cap_cents is ignored, and how that shows depends on
the endpoint:
POST /v1/quoteswithprepaidrefuses abusiness_fee_shareother than 100 with422 validation_failednamingbusiness_fee_share. Send 100 or leave it out: with no split fields the quote is priced at 100, whatever your store’s default split is. A quote that names nopayment_methodis priced exactly the same way, because the only delivery that can bind it is aprepaidone: it refuses a share other than 100 too, with a message that says so and that you can name thepayment_methodinstead. To split the fee with your customer, quote ascashorcard.POST /v1/deliverieswithprepaidand noquote_idforces the share to 100 and creates the order.POST /v1/deliverieswithprepaidand aquote_idneeds a quote that was requested withpayment_method: prepaidor with nopayment_method, either of which is always priced at 100. Any other quote is refused with422 validation_failed, naming the first thing that does not match. A quote requested ascashorcardis refused namingpayment_method. A quote whose split is not 100 with no ceiling (one saved before this rule) is refused namingbusiness_fee_share(orbusiness_fee_cap_cents), with a message that says to request a new quote asprepaid.
What Listo sends your customer
Listo talks to the customer of your orders itself, from Listo’s own WhatsApp number and in Spanish, so there is nothing for you to build to keep them informed. Two things go out:- The payment link, on
cardorders. The moment acardorder is created and waits for payment, Listo sends the customer a WhatsApp with their first name, your store’s name, what they owe and thepayment.urllink, and says the courier leaves as soon as they pay. If that WhatsApp does not reach them (the number is not on WhatsApp, or the message fails), Listo sends the same link by SMS instead, from a short code, so they still get it. It is sent once per order, with nothing for you to call. A link that is no longer live, because the order was paid or cancelled, is not sent. - The courier’s messages, on every order. Once a courier is assigned, when
they are on the way, when they are arriving and when the order is delivered,
Listo sends the customer a WhatsApp from the same number, with the tracking
link and, if you asked for one, the drop-off code. This includes
prepaidandcashorders: paying in your own checkout does not stop Listo from telling the customer where their order is.
Tipping the courier
tip_cents adds a tip for the courier, in cents, on top of everything else on
the order. It reaches the courier in full: Listo adds nothing to it and keeps
nothing from it.
It is optional, defaults to 0, and is capped at 100000 (MX$1,000). Anything
above that, or below zero, is refused with validation_failed naming
tip_cents before the order is priced, so an impossible tip costs you nothing.
A tip is not part of fee_cents and it is not split with your customer. Your
share of the fee and the tip come off your balance together as one movement when
the delivery is created, and if the delivery never happens they come back
together too: a canceled, returned or no_courier order refunds the tip
along with your fee share.
A tip is decided once, at create, and cannot be raised afterwards. There is no
call that adds a tip to a delivery already on the road.
Scheduling
Leavepickup_ready_at out and the courier is called immediately. Send it and
the order is scheduled: the courier is arranged now, for a pickup then.
It has to be at least 10 minutes out and at most 29 days out. Ten minutes is low
on purpose, because “food ready in fifteen” is the normal case for a machine,
not an edge one.
A scheduled order can be moved with PATCH /v1/deliveries/{id} while it is
still scheduled, including to now, which dispatches it. Once it has left
scheduled the window is fixed and the call returns 409 not_reschedulable.
Sending it by car
vehicle is moto, the default, or car. Send car for an order that does
not fit on a motorcycle.
- A car is a sedan. We can guarantee a car, never a van.
- There are fewer cars than motorcycles, so a car can take longer to assign.
- A car adds a surcharge to the delivery fee. It is inside
fee_centsand split like the rest of the fee, so the price you are quoted is the whole price, and there is no separate line for it.
manifest.items can also carry a size: small (the default),
medium, large or xlarge. An item sized xlarge makes the trip a car even
when vehicle says moto, because it will not fit on a motorcycle. No other
size does, and neither does the number of items: two large items are still a
motorcycle trip. Send the quote the same vehicle and the same manifest you
will create with, so the price you show is the price of the trip you book.
Both the quote and the delivery answer with vehicle: what the trip was priced
and dispatched as, whatever made it a car. It is not the vehicle that turns up.
That is courier.vehicle_type on the delivery, which can occasionally be a car
on a moto trip without the fee changing.
A cart too large for one courier trip is refused with 422 order_too_large,
naming manifest.items, before anything is priced or dispatched. Split it into
two deliveries.
The optional detail
manifest.itemsis a list of{name, quantity, size}for the courier to check against. It is descriptive: money comes fromorder_value_cents, and the one thing an item can change is the vehicle, through itssize(see sending it by car).handoffismeet_at_door(the default),leave_at_doorormeet_outside. Cash orders are always handed over in person.dropoff.notesare per-order instructions for this address, at most 280 characters. Standing instructions for the store belong on the store aspickup_notes.verification.dropoff_pincode: trueasks for a code the customer has to give the courier. It comes back on the delivery asverification.pincodeonce a courier has one. Listo also gives it to your customer in its own courier messages, so you only need to show it yourself if you want it on your screen.dropoff.latanddropoff.lngare optional. Leave them out and the street is located for you; send them and they are used as given. Send both or neither.
When an order comes back: the return code
When a delivery cannot be handed over, the courier brings the order back to the store and the delivery moves toreturning. To finish leaving it there, the
courier asks the store for a 4 digit code. That code is return_verification.pincode
on the delivery object. The courier needs it to finish the return, so your system
has to show it to the store’s staff.
return_verificationis always on the object.pincodeis a 4 digit string whilestatusisreturning, andnullin every other state, so show the code when it is there and hide it when it is not.- You do not need to poll for it. The
delivery.status_changedwebhook for the move toreturningcarries the whole delivery object, code included (see webhooks). - It is the store’s code, never the customer’s, and it is not
verification.pincode, which is the drop-off code the customer gives the courier. Do not send it to your customer. - Staff should give it to the courier only once they have the order in hand.
- Each attempt has its own code. A retried delivery is a new attempt, so it gets a new code.
- Once the delivery is
returnedthe code is spent andpincodegoes back tonull.
What you get back
A201 with the whole delivery object: the same shape you will read from
GET /v1/deliveries/{id}, from the list, and from every webhook. One shape
everywhere, so an object you stored at create is directly comparable to the one
an event hands you later. The full field list is in
the reference.
Two habits worth adopting now, because v1 changes only additively: ignore fields
you do not recognise rather than failing on them, and treat every id as an
opaque string you never parse.
When it is refused
Every refusal is the same envelope. Branch onerror.code, never on the
message, and retry only when error.retryable says you may.
One
out_of_service_area arrives after something was recorded. The courier
network can refuse a dropoff at the very edge of the circle on the create
itself, a moment after it priced the trip. The answer is the same 422, with
the same body, and the identical request with the same Idempotency-Key gets it
again. But the delivery existed by then, so it stays: ended at no_courier with
failure_reason: delivery_failed, holding your external_id, listed by
GET /v1/deliveries, and announced by a delivery.status_changed webhook. The
fee debited for it was returned in full, so your ledger shows the debit and its
refund side by side, and nothing else was charged. Retrying it only meets the
same refusal. To send that order to another address, create it with a new
external_id.
Listo never invents a price. An order it cannot price is refused rather than
taken at a made-up number, which is why provider_unavailable is a distinct
answer from a validation failure: one means try again, the other means change
something.
no_couriers_available is not an outage either. The courier network answered,
and what it said is that nobody near the trip is free right now, which is the
same fact a delivery that ended without a courier reports as
failure_reason: no_couriers_available. Give it a few minutes, retry a bounded
number of times, and then tell whoever is waiting for the order. The quote
answers it the same way.
After it is dispatched
GET /v1/deliveries/{id}reads one back, andGET /v1/deliverieslists them newest first, filtered bystatus,payment_status,store_id,external_id,originand a created window, and paged with an opaquecursor.POST /v1/deliveries/{id}/cancellationscalls it off, with a reason from Listo’s own list:customer_canceled,store_closed,item_unavailable,wrong_address,no_longer_needed, orotherwith adescription. It works from any non-final state, and cancelling an already cancelled delivery simply answers with it.POST /v1/deliveries/{id}/retriesre-dispatches a delivery that ended atno_courier. The id and yourexternal_idsurvive the retry, so it stays one order to you. If the courier network refuses the retried delivery’s distance at the edge of the service area, the retry answers422 out_of_service_area, which is final: the delivery stays atno_courierand nothing is charged.