> ## 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.

# Quickstart

> Your first delivery, end to end, on a test key.

By the end of this page you will have dispatched a delivery and watched it run
to the door. None of it costs money or moves a real courier: a test key acts in
a world of its own, where a simulated courier drives the whole lifecycle for
real, at real timings, emitting the same events and the same proof of delivery
a live one does.

You need one thing to start: a test API key, issued from the
[partner dashboard](https://partners.listo.delivery). Keys are shown once, at
the moment they are created. Never put a key in your source tree or ship it in
your own front end; it is the whole credential. A test key is the safe way to try
calls in the playground of these docs, because a live key there books and charges
real deliveries.

The samples below are Ruby, and they are plain HTTP against a base URL and a
bearer key, so they translate line for line into any language. Nothing to
install: they use only what Ruby itself ships, so `ruby check_your_key.rb` runs
them as they are.

**1. Check the key**

Every call carries `Authorization: Bearer <your key>`, and this one exists to
prove the key works before you build anything on it. It answers with the
account the key belongs to and the world it acts in.

```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
```

If it comes back `invalid_api_key` instead, the key is either revoked or from
the other mode. There is no third possibility: a key that authenticates at all
can do everything its mode allows, unless it was issued as read-only, in which
case it can read and every other call answers `403 read_only_key`.

**2. Register a pickup location**

A delivery is dispatched *from* a store, so you need at least one. Send the
name, a phone the courier can call, and the street; everything else is
optional.

```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
```

That `st_...` is the id you pass as `store_id` from here on. A store belongs
to the world it was created in and cannot move between them, so the ones you
make now are test stores and stay that way.

**3. Quote it, then dispatch it**

Two calls. The first asks what a delivery to a given address costs and holds
that price; the second dispatches and charges the price you were quoted.

The dropoff below is a real address in Guadalajara about 2.4 km from the
store, comfortably inside the area Listo covers. Use your own store's
neighbourhood when you adapt this.

```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
```

Note the `Idempotency-Key` header. It is required on every create, and it is
what makes a retry after a timeout safe: the same key returns the original
delivery rather than sending a second courier.

**4. Watch it move**

Open the tracking link and you will see the courier make its way to the store
and then to the door, on the same page your customers get. Within a few
minutes the delivery walks the whole lifecycle:

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

You can follow the same thing from your own code in two ways. Poll
`GET /v1/deliveries/{id}` and read `status`, which is fine while you are
finding your way around, or point a webhook endpoint at your server and be
told. The second is what a production integration does, and it is the next
page to read.

## Where to go next

* [Quote and create a delivery](https://docs.listo.delivery/en/guides/quote-and-create.md) covers the
  create body in full: scheduling, cash and card, the manifest, and every way
  the call can be refused.
* [Receive and verify webhooks](https://docs.listo.delivery/en/guides/webhooks.md) replaces polling with
  events, and shows how to check that one really came from Listo.
* [Test mode](https://docs.listo.delivery/en/concepts/test-mode.md) explains what the simulated courier
  does and does not do, and how the play money behaves.
* [The API reference](https://docs.listo.delivery/api-reference/authentication/check-that-your-api-key-works.md) is generated from Listo's own test
  suite, so every field and example on it is one the API demonstrably answers
  with.


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