Skip to main content
Dispatching an order is two calls. 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 the fee_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 has to agree with the quoteBinding a quote means the price is settled, so the create may not contradict the things that price was computed from. payment_method, order_value_cents, business_fee_share, business_fee_cap_cents, the dropoff street and the dropoff apt_unit must all match the quote, or the create is refused with validation_failed naming the field that disagrees. Change any of them and you need a new quote.The vehicle has to match too, and it is compared as a whole trip rather than as the vehicle field alone: a create whose manifest has an item sized xlarge is a car trip whatever vehicle says, so binding it to a quote priced as a moto is refused, naming manifest.items. Leave vehicle out and the create inherits the quote’s.The message on that refusal says why and what to do: when the split is what disagrees, it tells you to send the same split or request a new quote.Leaving one out is not a contradiction: an omitted field inherits what the quote was priced with. Sending business_fee_cap_cents on a create that binds a quote taken without a ceiling is a mismatch rather than an addition, so put the ceiling on the quote too.tip_cents is the exception, and the only one: quotes have no tip field, a tip never changes a fee, and any tip you send on a create is compatible with any quote you bind.

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/quotes with prepaid refuses a business_fee_share other than 100 with 422 validation_failed naming business_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 no payment_method is priced exactly the same way, because the only delivery that can bind it is a prepaid one: it refuses a share other than 100 too, with a message that says so and that you can name the payment_method instead. To split the fee with your customer, quote as cash or card.
  • POST /v1/deliveries with prepaid and no quote_id forces the share to 100 and creates the order.
  • POST /v1/deliveries with prepaid and a quote_id needs a quote that was requested with payment_method: prepaid or with no payment_method, either of which is always priced at 100. Any other quote is refused with 422 validation_failed, naming the first thing that does not match. A quote requested as cash or card is refused naming payment_method. A quote whose split is not 100 with no ceiling (one saved before this rule) is refused naming business_fee_share (or business_fee_cap_cents), with a message that says to request a new quote as prepaid.
See the money model.

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 card orders. The moment a card order is created and waits for payment, Listo sends the customer a WhatsApp with their first name, your store’s name, what they owe and the payment.url link, 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 prepaid and cash orders: paying in your own checkout does not stop Listo from telling the customer where their order is.
Send nothing of your own for either of these, or the customer hears it twice. Everything else you tell them, your order confirmation or your receipt, stays yours. Nothing about your balance, the fee split or what a delivery costs you is in any of Listo’s messages.

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.
Tips are for prepaid orders onlytip_cents is accepted on payment_method: prepaid. A tip above zero on a cash or a card order is refused with validation_failed naming tip_cents.Those two orders are ones Listo collects money for, and a tip declared here would be the wrong shape in both. On a cash order the courier is tipped hand to hand at the door, by the person who is already handing them money, and a declared tip would change what the courier has to collect and count against the cash limit. On a card order your customer pays through a Listo link, so a tip you fund on top of a charge they paid is a second, different tip. Prepaid is the one method where you have already settled with your customer yourself, so the tip is unambiguously yours to send.
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

Leave pickup_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_cents and 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.
Each item in 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.items is a list of {name, quantity, size} for the courier to check against. It is descriptive: money comes from order_value_cents, and the one thing an item can change is the vehicle, through its size (see sending it by car).
  • handoff is meet_at_door (the default), leave_at_door or meet_outside. Cash orders are always handed over in person.
  • dropoff.notes are per-order instructions for this address, at most 280 characters. Standing instructions for the store belong on the store as pickup_notes.
  • verification.dropoff_pincode: true asks for a code the customer has to give the courier. It comes back on the delivery as verification.pincode once 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.lat and dropoff.lng are 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 to returning. 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_verification is always on the object. pincode is a 4 digit string while status is returning, and null in 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_changed webhook for the move to returning carries 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 returned the code is spent and pincode goes back to null.
Listo shows the same code in your partner dashboard, on the delivery’s row, while the order is coming back and after it is back. The delivery object is still where your own system reads it from. The field is described in the reference.

What you get back

A 201 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 on error.code, never on the message, and retry only when error.retryable says you may.
The ones a create runs into: 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, and GET /v1/deliveries lists them newest first, filtered by status, payment_status, store_id, external_id, origin and a created window, and paged with an opaque cursor.
  • POST /v1/deliveries/{id}/cancellations calls it off, with a reason from Listo’s own list: customer_canceled, store_closed, item_unavailable, wrong_address, no_longer_needed, or other with a description. It works from any non-final state, and cancelling an already cancelled delivery simply answers with it.
  • POST /v1/deliveries/{id}/retries re-dispatches a delivery that ended at no_courier. The id and your external_id survive 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 answers 422 out_of_service_area, which is final: the delivery stays at no_courier and nothing is charged.
Where a cancellation leaves the money is the money model; how to be told about all of this without polling is webhooks.