Skip to main content
Every change to the v1 contract, newest first. Each release says what changed, some at length when the behaviour changed. The schema changes under a release are collapsed and generated by comparing that release’s OpenAPI document against the one before it, so they say exactly what the API says and nothing else. Release numbers name a published version of the schema. You cannot select one: every client always gets the latest. Changes to v1 are additive, except a change marked Breaking, which we tell you about before it ships. New fields and new enum values can appear at any time, so ignore what you do not recognise rather than failing on it.
an error's doc_url has no trailing slash, and the documentation moves to docs.listo.delivery
The doc_url of an error now has no trailing slash: .../en/errors/not_found instead of .../en/errors/not_found/. The documentation answers a page at the address without it, so use the value as it comes. The documentation now lives at https://docs.listo.delivery, and the guide links inside the descriptions of this reference point there; the old addresses under api.listo.delivery/docs answer a permanent redirect to the same page, except the OpenAPI document, which stays at https://api.listo.delivery/docs/openapi.yaml. The description of the bearer authentication scheme now also says to try calls with a test key (no real courier is sent and no real money moves, but webhooks fire and the dropoff.phone you give can receive WhatsApp messages, so use your own number), and that a live key books and charges real deliveries. Nothing else changes.
shared types for every response, the webhook events as one union, and what the server enforces
The reference now describes every response and every request body with a named shared type (Delivery, Store, Quote, Withdrawal, Error and the rest) instead of a copy for each endpoint, so a client generated from it has one Delivery to handle everywhere. The six webhook events, and the events you read from GET /v1/events, POST /v1/events/{event_id}/resend and POST /v1/test_webhooks, are one WebhookEvent union that narrows on type. It also says what the server already sends and checks: a field that can be null is typed that way, the closed lists (payment methods, handoffs, cancellation reasons, access tiers, withdrawal kinds) are published as enums, the limits on a request (a tip up to 100000 centavos, a markup up to 1000000, a fee share from 0 to 100, latitude and longitude ranges, the length of an external_id, of the notes and of a refund reason) are published, pickup_ready_at on a create and a quote is a date-time, and every write operation lists the 403 read_only_key it can answer. The endpoints that take no body (retries, the plan and its cancellation, the card onboarding) no longer list one. Latitude and longitude are published as double precision numbers (format: double), which is how the server sends them, so a generated client does not round a coordinate to 32 bits, and the list of delivery statuses now includes awaiting_location, the status of an order your store took through Listo’s console or WhatsApp assistant while it waits for the customer to confirm the drop-off location. Nothing the API sends or accepts changes.
every error links to its page and names the request, find a call by its request id, and read-only keys
Every error response now carries two more fields in error: doc_url, the page of this documentation that explains that error code and what to do about it, in the language you asked for with Accept-Language, and request_id, Listo’s id for the call, the same value as the X-Request-Id response header. Paste the request_id into a support request. You can also find the call yourself: GET /v1/request_logs has a new request_id filter (an exact match), and the dashboard’s Request logs page has a search box for it. A key can now be issued as read-only: it can call GET and HEAD and nothing else, and any other call with it is refused with the new 403 read_only_key error, so you can hand it to an AI agent or a dashboard that must not move money. Nothing that worked before changes.
developer earnings on the ledger, and a store with no plan
The ledger gains a developer_share type: what you earn on a delivered trip of your own store, credited to you when the trip is delivered. A store of a partner on pricing with no Plan Listo has plan null, because it needs no plan.
rehearse a return, a missing courier and a late pickup on a test key
POST /v1/deliveries accepts an optional simulate on a test key: returned brings the order back to the store, no_courier answers the same 409 no_couriers_available a live create does and creates nothing, and delayed_pickup sends a courier who collects the order about 25 minutes after the create. A live key that sends simulate is refused with 422 validation_failed.
see your own calls, events and delivery attempts, then resend an event or send a test one
You can now debug an integration yourself. GET /v1/request_logs and GET /v1/request_logs/{id} list the calls your keys made, with what you sent and what we answered. GET /v1/events and GET /v1/events/{id} list the events Listo emitted for you, and GET /v1/webhook_deliveries lists every attempt to deliver them and what your endpoint answered. POST /v1/events/{id}/resend sends one event to your endpoint again, POST /v1/test_webhooks sends a sample event of any type with "test": true, and four new error codes (webhook_endpoint_not_configured, event_not_resendable, resend_limit_reached and test_webhook_limit_reached) say when one is refused. A resent event is the snapshot from when it happened, so GET /v1/deliveries is still how you recover where things stand now.
the return code's description, wording only
The description of return_verification now says plainly what the code is for and when pincode is set: a 4 digit string while the delivery is returning, null otherwise. Nothing in the API changed.
every delivery carries a return code
Every delivery carries return_verification, whose pincode is the 4 digit return code the store gives the courier to complete a return. The object is always there, and pincode is a 4 digit string while status is returning, and null otherwise.
deliveries carry their proofs, and phone numbers are checked as E.164
Every delivery carries proofs, the evidence the trip left at pickup, drop-off and return, and the links in proof are now Listo’s own 24 hour signed links, null for the few seconds before Listo holds its copy. Phone numbers are checked more strictly as E.164, and a quote sent without payment_method is priced as prepaid (business_fee_share 100), so only a prepaid create can bind it.
Listo sends your customer the payment link and the courier's messages
On a card order, Listo now sends the customer the payment link itself. The moment the order is created and waiting for payment, it goes out as a WhatsApp from Listo’s own number, in Spanish, with the amount owed and payment.url, and if that WhatsApp does not reach the customer Listo sends the same link by SMS. payment.url is still on the response, so you can show it or keep it, but you no longer need to send it, and you should not: the customer would get the same link twice from two senders. Nothing in the schema changed, and no partner had created a card order before this, so no existing integration changes behaviour.We are also documenting something that was already true and that the docs denied. Listo was already sending the customer of every API order its courier messages, from the same number: courier assigned, on the way, arriving and delivered, with the tracking link and the drop-off code when you asked for one. That includes prepaid and cash orders, and the line saying Listo never meets your customer on a prepaid order was wrong. See what Listo sends your customer.
a plan awaiting its first payment, and a spent plan refuses deliveries
A store whose first Plan Listo month your balance and card could not cover now reads plan.status awaiting_payment instead of past_due, and Listo charges it by itself once they can. A plan whose included deliveries for the cycle are all used refuses deliveries with 402 plan_required.
each restaurant can pay its own Plan Listo
Your account can be set up so that each restaurant pays its own Plan Listo, and then a live store is created with plan null and cannot be api_only.
a quote fixes what the order is charged, and booking differences land on your ledger
GET /v1/ledger has two new row types: requote_adjustment, explained below, and listo_charge_correction, a charge Listo made in error and gave back.A quote used to read as a promise that you would pay exactly what you were quoted, however the underlying cost moved before the delivery was created. That promise is withdrawn. Binding a quote on POST /v1/deliveries still charges the order the quote’s fee_cents and split, and the total your customer sees never moves. What changes is what happens when the courier network books the trip at a slightly different price: the difference is no longer absorbed for you. It appears on your ledger as its own requote_adjustment row on GET /v1/ledger, negative when the booked trip cost more and positive when it cost less.This should be rare: a create now books the very quote you were shown whenever it is still valid, and asks for a new one only when it has expired or the order changed what it was priced for. A cash order always books a fresh quote, because the quote you were shown carries no amount for the courier to collect. requote_adjustment is a new type value, and type values grow in ordinary releases, so keep counting the ones you do not recognise toward your balance.
balances show the cash still in review
GET /v1/balance and GET /v1/stores/{store_id}/balance carry in_review_cents, cash still under review that deliveries cannot draw on.
a busy courier network answers 409 no_couriers_available
When every courier near a trip is busy, the courier network refuses to price it for the moment. That refusal used to reach you as 503 provider_unavailable, which says our dispatch is down, and your monitoring counted it as a server error. It now answers 409 no_couriers_available with retryable: true, on POST /v1/quotes and on POST /v1/deliveries, and nothing is created or debited. The identical request, with the same Idempotency-Key on a create, goes through once a courier is free, usually within minutes.no_couriers_available is the word a delivery that ended without a courier already carries in failure_reason, so one fact now has one name wherever you meet it. If you retry every 503 at once, this refusal no longer reaches that loop: retry it after a few minutes, a bounded number of times, and then tell whoever is waiting for the order. provider_unavailable still means what it says, a dispatch outage.
the service area is 15 km, and its edge refuses with out_of_service_area
The distance Listo delivers from a store is now 15 km in a straight line from the store’s position, up from 10 km. A quote or a delivery whose drop-off is farther than that is refused with 422 out_of_service_area, as before, at the new distance.A drop-off within a few tens of metres of that edge can also be refused by the courier network, which measures the same 15 km from its own record of the store. That refusal used to reach you as 503 provider_unavailable with retryable: true, which invited you to retry a distance no retry can change. It now answers 422 out_of_service_area with retryable: false, on POST /v1/quotes, on POST /v1/deliveries, and on POST /v1/deliveries/{id}/retries, where the delivery stays at no_courier and nothing is charged. A delivery the network refuses this way after it was created reads failure_reason: delivery_failed, as any other failure of ours does.Rarely, the refusal comes on POST /v1/deliveries itself, a moment after the network priced the trip. The create answers the same 422, with the same body, and replaying it with the same Idempotency-Key answers it again. The delivery had already been recorded by then, so it stays, ended at no_courier under your external_id, with its fee debit returned in full and nothing else charged. To send that order somewhere else, create it with a new external_id.Repeating a create with the same Idempotency-Key while the original request is still being processed, its dispatch included, answers 409 idempotency_conflict (in progress) until the original has answered, and from then on the replay returns the original’s answer, that 422 included. A repeat that arrived while the dispatch was still running used to be answered 201 from the delivery, even when the original went on to answer 422.If you already branch on out_of_service_area, there is nothing to change. If you retry every 503, a drop-off at the edge now stops at the first answer instead of at your retry limit.
the drop-off arrival time is null once a drop-off fails
dropoff_eta_at is when the courier is expected at the customer. After a failed drop-off it used to carry a different time: from the moment a delivery entered returning it held when the courier was expected back at the store, kept moving as the courier drove there, and stayed on the delivery once it was returned. It is now null on a returning and on a returned delivery, in every response and every webhook that carries a delivery, because the customer is not receiving anything and there is no arrival at their door left to describe. The time the order gets back to the store is not published in its place.If you show dropoff_eta_at to your customers or your merchants as an arrival time, a returning order now shows none, where before it showed when the food would be back in the kitchen as though it were arriving at the customer. returned still marks the moment the order is back.Nothing about the request or the response shape moved: the field is still always present, and null was already one of its values before the courier had an estimate.
every live store carries a Plan Listo, charged to you
Every store you create with a live key now carries one Plan Listo, and the plan is charged to you: MX$1,990 plus IVA a month for each store at list price, or the price you agreed with Listo. POST /v1/stores with a live key charges the new store’s first month in the same request, from your live balance or from the card saved on your account, so fund one of them before you create a store and send an Idempotency-Key on every live create. A month paid from the balance appears on GET /v1/ledger as a plan_charge row, and every ledger row now carries store_id, the store it belongs to, or null when it belongs to none.A live store whose plan is unpaid or has ended has its quotes, deliveries and retries refused with 402 plan_required, whatever your balance holds. Every store object carries a new plan block, which is null on a test store and on a live store created before Plan Listo existed: those keep requesting couriers as before. Three new endpoints manage a plan: POST /v1/stores/{id}/plan_cancellation ends it when the period already paid runs out, DELETE on the same path undoes that, and POST /v1/stores/{id}/plan restarts one that ended. A restaurant that connects an account of its own and already pays its own plan keeps paying it until its next renewal, and from then on it is charged to you. The store object in store.updated and store.connected now documents plan, access_tier, billing and markup_cents as always present; the last three were already sent on every event, and the reference now says so. Every plan block also carries courtesy, always present: it is true while Listo waives the store’s plan, when nothing is charged and next_charge_on is null.Everything about it is in plan and billing.
deliveries by car, and a cart too large for one trip is refused
A quote and a delivery take vehicle (moto or car), and manifest.items[].size (small, medium, large or xlarge) decides when a trip needs a car; a cart too large for one trip is refused with 422 order_too_large.
a connected restaurant's stores carry the business name
A store’s name is the name you gave it, and for every store you created that is unchanged. A restaurant that connects an account with several branches now arrives with each store named after the business and the branch, Tortería La Mera Mera, Zapopan, the way its own customers read it, where it used to arrive as the bare branch (Zapopan). A branch whose own name already begins with the business’s name, such as Tortería La Mera Mera Centro, is published as it is. The same name comes back from the exchange, on store.connected and store.updated, and on GET /v1/stores. When a connected restaurant renames its business, you receive a store.updated for each of its stores, carrying the new name.Renaming a store with PATCH /v1/stores/{id} now renames its business with it when the two carry one name, which every store you created does. Before, only the store’s own name moved, so what the restaurant’s customers were shown still led with the old one. A branch of a restaurant with several branches, or a store whose name already differs from its business’s, is renamed alone and reads as the business followed by the new name. Sending back the name you read is safe: a name that begins with the business’s name and a comma is read as the business and the branch, so it renames nothing, and Tortería La Mera Mera, Centro renames only the branch.A store.updated is no longer sent when the store object it would carry is exactly the one you were last sent for that store, so a save that changes nothing you can see sends nothing, and one change is one event. The event for a store whose card readiness changed is sent as before, although its object does not change.Nothing about the request or the response shape moved.
same-day withdrawals, and a refusal while a pickup point is confirmed
Withdrawals can be same-day: POST /v1/withdrawals takes kind, GET /v1/balance carries same_day, and a withdrawal carries kind, fee_cents and due_at. A store whose pickup point is still being confirmed is refused with a retryable 409 pickup_point_pending.
a store's timezone follows its position, and deliveries can originate on WhatsApp
A delivery’s origin has a third value, whatsapp: a delivery created from an order a customer placed on WhatsApp and the store accepted.POST /v1/stores and PATCH /v1/stores/{id} accept a timezone, and a zone you sent used to be kept exactly as sent. It is now the fallback: the store’s own position decides its zone, and what you send is used only when the position cannot answer (a store with no coordinates, or a position no zone claims).Sending a zone that disagrees with the position changes nothing and is not an error. The store is created or updated, everything else you sent is applied, and the response carries the zone the position gives, so what you read back is always what the store actually keeps.The timezone change moves nothing in the request or the response shape, and the field is not deprecated. What changed is which of two answers wins. We reversed it because we cannot tell the two cases apart from here: a zone sent by default out of a form that never asked its user arrives looking exactly like a zone somebody chose, and a store on the wrong clock silently moves its opening hours, its delivery timestamps and the week its earnings settle in. If a store’s clock looks wrong to you, its coordinates are what to fix.
stores can split the delivery fee by a fixed amount
A store’s default_fee_split_mode and default_fee_split_amount_cents tell you when the restaurant splits the delivery fee by a fixed peso amount instead of a percentage, so read the mode before default_business_fee_share.
a wasted trip is billed at the delivery fee
A provider_charge on GET /v1/ledger, and the matching entry in a delivery’s charges array, used to be what the wasted trip cost Listo. It is now the delivery fee for each leg that was actually driven, less your own store markup: one leg for a cancellation late enough that a courier was already working on the order, two for a return trip after a failed drop-off, and none at all when no leg was driven.Nothing about the response shape moved. The same fields and the same types come back, and no integration has to change. What changes is the amount: expect larger values on the same orders from this date, and if you bill these charges on to your own merchants, bill what the row says rather than a figure you worked out before.
editable drop-off notes on the way, and stores outside Mexico are refused
PATCH /v1/deliveries/{id} now also takes dropoff.notes, so you can correct what the courier is told about the drop-off while the order is on its way, and it answers 409 not_editable once it is too late. A store outside Mexico is refused with 422 unsupported_country. The PATCH reference also stops listing four request fields the endpoint never read.
a delivery whose drop-off failed is now returning
A delivery whose drop-off failed now has the status returning while the courier brings the order back to the store, until it ends in returned. Its charges are already complete on the first returning event.
refund your customer's card payment
You can refund your customer’s card payment with POST /v1/deliveries/{delivery_id}/refunds, and every delivery carries refunds, refunded_cents and refundable_cents.
withdrawals carry a tracking key and need a registered bank account
A withdrawal now carries clave_de_rastreo, the SPEI tracking key your bank uses to find the deposit, once it is paid, and POST /v1/withdrawals is refused with 422 payout_account_not_ready until you have a bank account registered.
connect a restaurant that already has a Listo account
POST /v1/connect_requests and POST /v1/connections connect a restaurant that already has a Listo account, with its owner’s approval, and the new store.connected webhook tells you when it happens. POST /v1/stores also accepts owner_email.
your own store ids, idempotent store creation and a delivery origin
A store carries your own external_id, which you can send on create and update and filter GET /v1/stores by, and POST /v1/stores honours Idempotency-Key, answering 409 for a key reused with another body. Each delivery also says where it came from in origin, which GET /v1/deliveries can filter by.
deliveries accept a tip for the courier
POST /v1/deliveries accepts tip_cents, a tip for the courier that every delivery carries back. The reference now marks charges, access_tier, billing and markup_cents as always present, which they already were.
a cap on your share of the delivery fee
POST /v1/quotes and POST /v1/deliveries accept business_fee_cap_cents, a ceiling in cents on your share of the delivery fee.
stores have an access tier and a markup, with their own balance and ledger
Stores now have an access_tier (api_only, wallet or full) and a markup_cents, and billing says whose balance pays their deliveries. A store’s own balance and ledger are at GET /v1/stores/{store_id}/balance and GET /v1/stores/{store_id}/ledger, and POST /v1/stores/{store_id}/owner invites the person who runs it to its Listo console.
deliveries carry their charges, and the ledger lists your balance movements
Each delivery now carries charges, what a cancelled or returned trip cost you, and GET /v1/ledger lists every movement on your balance. The reference also shows the shape of a retry that fails.
the first published version
The first published version of the Partner Delivery API.