> ## Documentation Index
> Fetch the complete documentation index at: https://docs.listo.delivery/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> Notes on the Listo Partner Delivery API:
> - Every endpoint is under `https://api.listo.delivery/v1/` and authenticates with a bearer API key.
> - This documentation site is at https://docs.listo.delivery. The full machine-readable reference is the OpenAPI document at https://api.listo.delivery/docs/openapi.yaml (served by the API host, not by this site).
> - Any page of this site is also available as raw markdown by appending `.md` to its URL, for example https://docs.listo.delivery/en/guides/webhooks.md. The index of every page is https://docs.listo.delivery/llms.txt and all of them in one file is https://docs.listo.delivery/llms-full.txt.
> - Every page is published in English under https://docs.listo.delivery/en/ and in Spanish under https://docs.listo.delivery/es/, with the same path after the language. The API reference is under https://docs.listo.delivery/api-reference/ in English and https://docs.listo.delivery/es/api-reference/ in Spanish.
> - To debug an integration yourself: `GET /v1/request_logs` lists the calls your keys made with what you sent and what was answered, `GET /v1/events` lists the events Listo emitted, `GET /v1/webhook_deliveries` lists every attempt to deliver them, `POST /v1/events/{id}/resend` sends one event again and `POST /v1/test_webhooks` sends a sample event (`"test": true`). The page https://docs.listo.delivery/en/guides/debugging describes them. A resent event is a snapshot from when it happened; `GET /v1/deliveries` is still how you recover current state.
> - Every error response has `error.doc_url`, a page that explains that error code and what to do, and `error.request_id`, the same value as the `X-Request-Id` response header. All the pages are listed at https://docs.listo.delivery/en/errors. Filter `GET /v1/request_logs` with `request_id` to read the logged call.
> - A `409 account_exists` from `POST /v1/stores` means the restaurant already has its own Listo account: connect it instead of creating it, as the page https://docs.listo.delivery/en/guides/connect-existing-account describes.
> - Every live store carries one Plan Listo, charged to the partner. The exception is a connected restaurant that had already paid its current month: it keeps paying its own plan until its next renewal, shown as `paid_by: "store"` with the handover date in `pending_paid_by_on`. A `402 plan_required` from a quote, a delivery create or a retry means the store's plan is not paid or has ended; the page https://docs.listo.delivery/en/guides/plan-and-billing describes the plan, the `plan` block on a store and how to end or restart it.
> - An AI assistant (Claude Code, Cursor, Codex) can act through the same API key at `https://api.listo.delivery/mcp`, an MCP server over Streamable HTTP authenticated with `Authorization: Bearer <API key>`. Every tool runs the matching `/v1/` endpoint, and each one that moves money first previews and then needs a confirmation token; the page https://docs.listo.delivery/en/guides/connect-ai-client lists the tools and describes the two steps.

# Inicio rápido

> Tu primer envío, de principio a fin, con una llave de prueba.

Al terminar esta página habrás despachado un envío y lo habrás visto llegar hasta
la puerta. Nada de esto cuesta dinero ni mueve a un repartidor real: una llave de
prueba actúa en un mundo aparte, donde un repartidor simulado recorre de verdad
todo el ciclo, con tiempos reales, emitiendo los mismos eventos y la misma prueba
de entrega que uno de producción.

Solo necesitas una cosa para empezar: una llave de API de prueba, que se emite
desde el [panel de socios](https://partners.listo.delivery). Las llaves se
muestran una sola vez, en el momento en que se crean. Nunca pongas una llave en tu
código ni la mandes al navegador desde tu propia app web; es la credencial
completa. Una llave de prueba es la forma segura de probar llamadas en esta
documentación, porque una llave real ahí reserva y cobra envíos reales.

Los ejemplos de abajo están en Ruby, y son HTTP plano contra una URL base y una
llave bearer, así que se traducen línea por línea a cualquier lenguaje. No hay
nada que instalar: usan solo lo que Ruby trae de fábrica, así que
`ruby check_your_key.rb` los corre tal cual.

**1. Revisa la llave**

Cada llamada lleva `Authorization: Bearer <your key>`, y esta existe para
comprobar que la llave funciona antes de que construyas algo sobre ella.
Responde con la cuenta a la que pertenece la llave y el mundo en el que actúa.

```ruby check_your_key.rb theme={null}
# Confirms an API key works, and tells you which world it acts in.
#
#   LISTO_API_KEY=lst_test_... ruby check_your_key.rb
#
require "json"
require "net/http"
require "uri"

BASE_URL = ENV.fetch("LISTO_API_BASE_URL", "https://api.listo.delivery")
API_KEY = ENV.fetch("LISTO_API_KEY")

# The refusal envelope every Listo error carries, or a stand-in built from the
# status when the body is not that envelope at all. Something between you and
# Listo, a proxy or a load balancer, can answer with an HTML error page or with
# JSON of some other shape, and the code that handles a failure must not itself
# fail trying to read it.
def parse_error(response)
  stand_in = {"code" => "HTTP #{response.code}", "param" => nil}
  body = JSON.parse(response.body.to_s)
  error = body["error"] if body.is_a?(Hash)
  error.is_a?(Hash) ? error : stand_in
rescue JSON::ParserError
  stand_in
end

uri = URI("#{BASE_URL}/v1/ping")
request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer #{API_KEY}"

response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http|
  http.request(request)
end

# Check the status BEFORE parsing the body. `error.code` is the part to branch
# on, never the message. `invalid_api_key` here means the key is missing,
# revoked, or was pasted from the other mode.
unless response.code == "200"
  abort("the key was refused: #{parse_error(response)["code"]}")
end

body = JSON.parse(response.body)

puts "key accepted for #{body.dig("partner", "name")}"
puts "mode: #{body["mode"]}"
```

```
key accepted for Tortas Express
mode: test
```

Si en cambio responde `invalid_api_key`, la llave está revocada o es del otro
modo. No hay una tercera posibilidad: una llave que se autentica puede hacer
todo lo que su modo permite, salvo que se haya emitido de solo lectura: en ese
caso puede leer y cualquier otra llamada responde `403 read_only_key`.

**2. Registra un punto de recolección**

Un envío se despacha *desde* una tienda, así que necesitas al menos una. Envía
el nombre, un teléfono al que el repartidor pueda llamar y la calle; todo lo
demás es opcional.

```ruby create_store.rb theme={null}
# Registers a pickup location. Every delivery is dispatched from one, so this
# is the first call an integration makes, and the `st_...` it returns is what
# you send as `store_id` from then on.
#
#   LISTO_API_KEY=lst_test_... ruby create_store.rb
#
require "json"
require "net/http"
require "uri"

BASE_URL = ENV.fetch("LISTO_API_BASE_URL", "https://api.listo.delivery")
API_KEY = ENV.fetch("LISTO_API_KEY")

# The refusal envelope every Listo error carries, or a stand-in built from the
# status when the body is not that envelope at all. Something between you and
# Listo, a proxy or a load balancer, can answer with an HTML error page or with
# JSON of some other shape, and the code that handles a failure must not itself
# fail trying to read it.
def parse_error(response)
  stand_in = {"code" => "HTTP #{response.code}", "param" => nil}
  body = JSON.parse(response.body.to_s)
  error = body["error"] if body.is_a?(Hash)
  error.is_a?(Hash) ? error : stand_in
rescue JSON::ParserError
  stand_in
end

uri = URI("#{BASE_URL}/v1/stores")

request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer #{API_KEY}"
request["Content-Type"] = "application/json"
request.body = JSON.generate(
  {
    "name" => "Torteria La Mera Mera",
    "phone" => "+523312345678",
    "street" => "Av. Juan Palomar y Arias 765, Zapopan, Jalisco 45110",
    # Optional. Leave them out and the street is located for you; send them and
    # they are used as given, which is worth doing for an address that is hard
    # to find from text alone.
    "lat" => 20.691145,
    "lng" => -103.426885,
    # Standing instructions for the courier at this location, on every order.
    "pickup_notes" => "Counter at the back, ask for the delivery shelf",
    # How much of the delivery fee this store's orders charge to your balance
    # rather than to the customer, as a percentage. Any order can override it.
    "default_business_fee_share" => 0
  }
)

response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http|
  http.request(request)
end

# Check the status BEFORE parsing the body. `error.param` names the field that
# was refused, when one field is at fault.
unless response.code == "201"
  error = parse_error(response)
  field = error["param"] ? " (#{error["param"]})" : ""
  abort("store refused: #{error["code"]}#{field}")
end

body = JSON.parse(response.body)

puts "store #{body["id"]} created"
puts "test mode: #{body["test"]}"
```

```
store st_4bR8kM2wQ7xT1nV5cZ9pLdYe created
test mode: true
```

Ese `st_...` es el id que pasas como `store_id` de aquí en adelante. Una tienda
pertenece al mundo en el que fue creada y no puede moverse entre ellos, así que
las que hagas ahora son tiendas de prueba y así se quedan.

**3. Cotízalo y despáchalo**

Dos llamadas. La primera pregunta cuánto cuesta un envío a una dirección dada
y aparta ese precio; la segunda despacha y cobra el precio que te
cotizaron.

La dirección de entrega de abajo es una dirección real en Guadalajara, a unos
2.4 km de la tienda, cómodamente dentro del área que Listo cubre. Usa la
colonia de tu propia tienda cuando adaptes esto.

```ruby quote_and_create.rb theme={null}
# Prices a delivery, then dispatches it at the price it was quoted.
#
#   LISTO_API_KEY=lst_test_... LISTO_STORE_ID=st_... ruby quote_and_create.rb
#
require "json"
require "net/http"
require "uri"

BASE_URL = ENV.fetch("LISTO_API_BASE_URL", "https://api.listo.delivery")
API_KEY = ENV.fetch("LISTO_API_KEY")
STORE_ID = ENV.fetch("LISTO_STORE_ID")

# Your own reference for this order. It is what you will recognise the delivery
# by in every response and every webhook, so use the id your system already has.
ORDER_ID = "order-841"

# The refusal envelope every Listo error carries, or a stand-in built from the
# status when the body is not that envelope at all. Something between you and
# Listo, a proxy or a load balancer, can answer with an HTML error page or with
# JSON of some other shape, and the code that handles a failure must not itself
# fail trying to read it.
def parse_error(response)
  stand_in = {"code" => "HTTP #{response.code}", "param" => nil}
  body = JSON.parse(response.body.to_s)
  error = body["error"] if body.is_a?(Hash)
  error.is_a?(Hash) ? error : stand_in
rescue JSON::ParserError
  stand_in
end

def post(path, payload, headers = {})
  uri = URI("#{BASE_URL}#{path}")

  request = Net::HTTP::Post.new(uri)
  request["Authorization"] = "Bearer #{API_KEY}"
  request["Content-Type"] = "application/json"
  headers.each { |name, value| request[name] = value }
  request.body = JSON.generate(payload)

  response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http|
    http.request(request)
  end

  # Check the status BEFORE parsing the body. Every refusal from Listo answers
  # with the same envelope, so branch on `error.code`, never on the message, and
  # retry only when `error.retryable` says you may.
  unless response.code.start_with?("2")
    abort("POST #{path} refused: #{parse_error(response)["code"]}")
  end

  JSON.parse(response.body)
end

dropoff = {
  "name" => "Ana Torres",
  "phone" => "+528112345678",
  "street" => "Blvd. Puerta de Hierro 4965, Zapopan, Jalisco 45116",
  "lat" => 20.709395,
  "lng" => -103.413393
}

# 1. Ask what the delivery costs. The price is held until `expires_at`, so you
# can show it to your customer before anyone commits to anything. No money
# moves and no courier is called.
quote = post(
  "/v1/quotes",
  {
    "store_id" => STORE_ID,
    "payment_method" => "cash",
    "order_value_cents" => 19_600,
    "dropoff" => dropoff
  }
)

puts "fee #{quote["fee_cents"]} #{quote["currency"]}, of which you pay #{quote["business_fee_cents"]}"
puts "the courier collects #{quote["cash_to_collect_cents"]} at the door"

# 2. Dispatch it at exactly that price by sending the quote id back.
#
# The Idempotency-Key is REQUIRED. Send the same one again after a timeout and
# you get the original delivery back instead of a second courier; send it with
# a different body and the call is refused with `idempotency_conflict`. Key it
# on your order, not on the attempt.
delivery = post(
  "/v1/deliveries",
  {
    "store_id" => STORE_ID,
    "quote_id" => quote["id"],
    "external_id" => ORDER_ID,
    "payment_method" => "cash",
    "order_value_cents" => 19_600,
    "dropoff" => dropoff
  },
  {"Idempotency-Key" => "create-#{ORDER_ID}"}
)

puts "delivery #{delivery["id"]} is #{delivery["status"]}"
puts "follow it at #{delivery["tracking_url"] || "(no link until a courier is assigned)"}"
```

```
fee 8004 MXN, of which you pay 0
the courier collects 27604 at the door
delivery dlv_7hK2mQ9xR4tV6bN1cZ8sLwYe is dispatching
follow it at https://app.listo.delivery/t/abc123
```

Fíjate en el encabezado `Idempotency-Key`. Es obligatorio en cada creación, y
es lo que hace seguro un reintento después de un timeout: la misma llave
devuelve el envío original en lugar de mandar un segundo repartidor.

**4. Mira cómo avanza**

Abre el enlace de rastreo y verás al repartidor ir hasta la tienda y después
hasta la puerta, en la misma página que reciben tus clientes. En pocos minutos
el envío recorre todo el ciclo:

```
dispatching -> courier_assigned -> picking_up -> en_route -> delivered
```

Puedes seguir lo mismo desde tu propio código de dos maneras. Consultando
`GET /v1/deliveries/{id}` y leyendo `status`, lo cual está bien mientras te
familiarizas, o apuntando un endpoint de webhook a tu servidor para que te
avisen. Lo segundo es lo que hace una integración en producción, y es la
siguiente página por leer.

## A dónde ir después

* [Cotiza y crea un envío](https://docs.listo.delivery/es/guides/quote-and-create.md) cubre el cuerpo de
  la creación completo: programación, efectivo y tarjeta, el manifiesto y todas
  las formas en que la llamada puede rechazarse.
* [Recibe y verifica webhooks](https://docs.listo.delivery/es/guides/webhooks.md) reemplaza el sondeo por
  eventos, y muestra cómo comprobar que uno viene de verdad de Listo.
* [Modo de prueba](https://docs.listo.delivery/es/concepts/test-mode.md) explica qué hace y qué no hace el
  repartidor simulado, y cómo se comporta el dinero de juguete.
* [La referencia de la API](https://docs.listo.delivery/es/api-reference/authentication/check-that-your-api-key-works.md) se genera desde las propias pruebas
  de Listo, así que cada campo y cada ejemplo que aparece ahí es uno con el que la
  API responde de forma demostrable.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.