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

# Conecta un restaurante que ya usa Listo

> Pídele a su dueño que conecte la cuenta de Listo que ya tiene, en lugar de crear una segunda.

La mayoría de las tiendas que agregas son nuevas para Listo, y las creas con
[`POST /v1/stores`](https://docs.listo.delivery/es/api-reference/stores/create-a-store). Algunos
restaurantes ya usan Listo con una cuenta propia: su propia consola, su propio
saldo, su propio historial de envíos. Para uno de esos no creas una tienda. Le
pides a su dueño que conecte la cuenta que ya tiene, la aprueba en una página de
Listo y tú recibes la tienda.

En el camino no se crea nada. Lo que terminas teniendo es una tienda común y
corriente, y todos los demás endpoints la tratan exactamente igual que a una que
tú creaste.

> [!WARNING]
>
> **Solo con llaves reales**
>
> Este flujo funciona con una llave real y con nada más. Una llave de prueba
> recibe `404 not_found` de los dos endpoints de esta página, porque no existe una
> versión de prueba de la cuenta real de un restaurante real. Lo que ensayas en
> modo de prueba es `POST /v1/stores`, que es lo que corre siempre que el dueño no
> tiene una cuenta propia.

## Crear o conectar

`owner_email` en `POST /v1/stores` es como sabes cuál de las dos cosas vas a
hacer. Es una comprobación, no un campo de la tienda: mandas la dirección de
quien lleva el local y, antes de escribir nada, Listo revisa si esa dirección ya
tiene acceso a la cuenta propia de algún restaurante en Listo.

* **No hay ninguna.** La tienda se crea, y de ahí el camino es el de siempre:
  ajusta su `access_tier` (ve [el modelo de dinero](https://docs.listo.delivery/es/concepts/money.md)) y,
  si alguien del restaurante va a entrar,
  [dale un acceso](https://docs.listo.delivery/es/guides/balance-and-withdrawals.md#cómo-darle-acceso-al-dueño-de-una-tienda).
* **Hay una.** La creación responde `409 account_exists` y no se crea nada. Una
  segunda cuenta para una misma cocina partiría en dos su saldo, su consola y su
  historial. Ese `409` es la puerta de entrada a esta página.

```json theme={null}
{
  "error": {
    "code": "account_exists",
    "message": "That email already has a Listo account. Use a connect request to ask its owner to connect it.",
    "param": null,
    "retryable": false
  }
}
```

Nada guarda la dirección y ningún objeto de tienda la devuelve. Solo decide cuál
de los dos caminos tomas.

## Primero registra tu URI de redirección

Cuando el dueño decide, Listo manda su navegador de regreso contigo, y solo a
una dirección que tú registraste. Agrega tu callback en la página **URIs de
redirección** del [panel de socios](https://partners.listo.delivery) antes de tu
primera petición.

* **Se compara la cadena completa.** Registra la URL completa de tu callback, no
  solo el origen. No hay comodines ni prefijos, así que
  `https://partner.example.com/listo/callback` no permite también
  `https://partner.example.com/listo/callback/done`.
* **Tiene que ser `https://`**, excepto en `localhost` y `127.0.0.1`, donde se
  permite `http://` para que puedas desarrollar en tu propia máquina.
* **La lista es de tu empresa, no de un modo**, así que no cambia cuando cambias
  entre real y prueba en el panel. Registra tu callback de producción y el que
  usas para desarrollar.
* **Una dirección que no está en la lista se rechaza cuando pides el enlace**,
  con `422 validation_failed` y `param: "redirect_uri"`. Falla en tu propio
  servidor, antes de que a cualquier restaurante se le muestre nada.

## Pide el enlace

[`POST /v1/connect_requests`](https://docs.listo.delivery/es/api-reference/account-connection/ask-a-restaurant-to-connect-its-account)
recibe la dirección del dueño, una de tus URIs de redirección registradas y, si
quieres, un `state` propio:

```json theme={null}
{
  "owner_email": "rosa@lataqueria.mx",
  "redirect_uri": "https://partner.example.com/listo/callback",
  "state": "sess-8f21"
}
```

Responde `201` con un enlace y una fecha límite:

```json theme={null}
{
  "url": "https://admin.listo.delivery/connect/pcr_6mT4qX8vN2kR7bW1cZ5sLdYe",
  "expires_at": "2026-08-10T18:31:00Z"
}
```

Es todo lo que te dice. Listo averigua a qué restaurante pertenece la dirección y
se lo guarda: sabes qué cuenta conectaste solo si el dueño aprueba.

El enlace sirve 30 minutos desde que lo pides. Pedir otro no cuesta nada, así
que un enlace vencido es un botón que se vuelve a mostrar, no un error que
manejar. Pídelo cuando el dueño esté por usarlo, no con horas de anticipación.

`state` es tuyo: cualquier valor de hasta 255 caracteres, que Listo nunca lee y
te regresa intacto. Es como amarras el navegador que regresa con la sesión que
salió.

```ruby connect_request.rb theme={null}
# Asks a restaurant that already runs on Listo, with an account of its own, to
# connect that account to you, and gets back the link to send its owner to.
#
# Live keys only: there is no test version of a real restaurant's real account,
# so a test key gets `not_found` here.
#
#   LISTO_API_KEY=lst_live_... ruby connect_request.rb
#
require "json"
require "net/http"
require "securerandom"
require "time"
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

# Your own value, sent back untouched on the redirect whichever way the owner
# decides. Keep it with the session that is asking, and when the browser comes
# back, act only on a redirect carrying the `state` you stored for it.
state = SecureRandom.urlsafe_base64(24)

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

request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer #{API_KEY}"
request["Content-Type"] = "application/json"
request.body = JSON.generate(
  {
    # The address the restaurant's owner signs in to Listo with. Listo works
    # out which account it belongs to and tells you nothing about it here.
    "owner_email" => "rosa@lataqueria.mx",
    # Must be registered, character for character, on the Redirect URIs page
    # of the partner dashboard, or this call is refused naming `redirect_uri`.
    "redirect_uri" => "https://partner.example.com/listo/callback",
    "state" => state
  }
)

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: `owner_email` when no account of its own signs in with that
# address, or more than one does; `redirect_uri` when it is not registered.
unless response.code == "201"
  error = parse_error(response)
  field = error["param"] ? " (#{error["param"]})" : ""
  abort("connect request refused: #{error["code"]}#{field}")
end

body = JSON.parse(response.body)

# `url` is the Listo page the owner signs in on and approves from. In your own
# product this is a redirect, a link or a button. It carries a token, so treat
# it like a password reset link: it goes to the owner and nowhere else, and it
# stays out of your logs. This sample prints its host and nothing more.
link = URI(body["url"])

# `expires_at` is an ISO-8601 instant in UTC, like every timestamp on this API.
# The link stops working then. Minting another one is free, so an expired link
# is a button to render again rather than an error to handle.
expires_at = Time.iso8601(body["expires_at"])
minutes_left = ((expires_at - Time.now) / 60).round

puts "send the owner to #{link.host}"
puts "the link expires in #{minutes_left} minutes"
```

```
send the owner to admin.listo.delivery
the link expires in 30 minutes
```

El enlace lleva un token que abre la página de consentimiento del dueño, y por
eso el ejemplo imprime solo su host. Mándaselo al dueño y a nadie más, y déjalo
fuera de tus logs.

## Qué ve el dueño

Manda el navegador del dueño a `url`, con una redirección, un enlace o un botón
en tu producto. Todo lo que sigue pasa en Listo. Esto es lo que lee, con las
palabras de su pantalla, para que tú y el restaurante estén viendo las mismas
frases si te llama.

**El inicio de sesión.** A menos que ese navegador ya tenga abierta su sesión en
la consola de Listo, llega a una página titulada "Conecta tu cuenta", que dice
"Inicia sesión para conectar *su restaurante* con *tu empresa*". Escribe el
correo y la contraseña que ya usa en Listo y presiona **Iniciar sesión y continuar**.
Aquí no hay registro: la única otra salida de la página es "¿Sin cuenta de
Listo? Regresa a *tu empresa*", que lo trae de regreso contigo con
`error=access_denied`.

**La pantalla de consentimiento.** Se titula "*Tu empresa* quiere conectar con
tu cuenta" y dice "Autoriza la conexión para que *tu empresa* envíe y administre
entregas con tu cuenta de Listo." Muestra su restaurante y la dirección con la
que entró, y después lo que está otorgando, bajo "Al conectar, *tu empresa*
podrá:"

* Crear entregas a nombre de tu negocio
* Ver tus entregas y su estado
* Consultar tu saldo de entregas

Cierra con "Tus entregas se siguen pagando desde tu saldo de Listo, como hasta
ahora." y dos botones, **Permitir conexión** y **Cancelar**.

Cuando el restaurante tiene más de una sucursal, la pantalla de consentimiento
muestra también cada sucursal que se va a conectar, porque aprobar las conecta
todas a la vez (ve [qué es una tienda conectada](#qué-es-una-tienda-conectada)).

Los dos nombres de esas páginas vienen de Listo, nunca de tu petición: *tu
empresa* es el nombre de tu empresa tal como Listo lo tiene en tu cuenta de
socio, y *su restaurante* es el nombre del negocio del restaurante en Listo, la
cuenta completa y no una de sus sucursales. Ningún campo de esta API pone
palabras en esas páginas.

Solo un dueño de la cuenta puede aprobar, y no tiene que ser la persona cuya
dirección mandaste: esa dirección solo es como Listo encontró la cuenta. A
alguien del personal que entra se le dice "Solo un propietario puede autorizar
esta conexión."

Un enlace que venció o se canceló abre una página titulada "Esta solicitud ya no
es válida"; un enlace que el dueño ya aprobó abre una titulada "Esta conexión ya
está aprobada". Las dos tienen un botón para regresar contigo, y ese botón los
regresa contigo con el resultado que ese enlace ya tenía.

## El navegador regresa

Cuando el dueño decide, Listo manda su navegador a tu `redirect_uri`:

| El dueño | Su navegador llega a |
| - | - |
| Aprobó | `https://partner.example.com/listo/callback?code=pcc_...&state=sess-8f21` |
| Canceló | `https://partner.example.com/listo/callback?error=access_denied&state=sess-8f21` |

`error=access_denied` significa que no se conectó nada y que ese enlace ya
terminó, ya sea que el dueño haya presionado **Cancelar** en la pantalla de
consentimiento, se haya regresado desde la página de inicio de sesión o haya
abierto un enlace que ya no era válido. No se puede reabrir, así que si el dueño
cambia de opinión, pide uno nuevo. Si el dueño vuelve a abrir un enlace que ya
aprobó, su navegador regresa contigo con el mismo `code` otra vez; canjearlo dos
veces es seguro.

Listo no le manda ningún correo al dueño por esto, apruebe o cancele. Si quieres
confirmarle la conexión, ese mensaje te toca a ti.

`state` regresa exactamente como lo mandaste, en los dos casos. Compáralo con el
valor que guardaste para esa sesión antes de actuar sobre la redirección, e
ignora una cuyo `state` no reconozcas: es tu protección contra un callback
falsificado. Si no mandaste `state`, no regresa ninguno. Si la URI que
registraste ya trae parámetros propios, estos se le agregan.

## El webhook no espera al navegador

Cuando el dueño aprueba, un evento
[`store.connected`](https://docs.listo.delivery/es/api-reference/webhooks/a-restaurant-connected-its-existing-account-to-you) va a tu endpoint de
webhooks real, y `data` es el objeto de tienda completo, `id` incluido. No
depende del navegador del dueño, así que puede llegarte antes que la redirección,
y te sigue llegando cuando la redirección nunca llega: un dueño puede aprobar en
el teléfono y cerrar la pestaña antes de que su navegador regrese contigo.

Da la tienda por conectada cuando te llegue cualquiera de los dos, lo que llegue
primero, y deduplica por el `id` del evento como con todos los eventos. Cómo
configurar el endpoint está en [recibe webhooks](https://docs.listo.delivery/es/guides/webhooks.md).

## Canjea el código

[`POST /v1/connections`](https://docs.listo.delivery/es/api-reference/account-connection/exchange-an-approval-for-the-store) recibe el
`code` de la redirección:

```json theme={null}
{ "code": "pcc_8vZ1nK5xQ3tR9bM7cW2sLdYe" }
```

Responde `201` con el objeto de tienda, exactamente la forma que regresa
`POST /v1/stores`, con el id `st_...` incluido. A partir de ahí es una tienda
común en todos los demás endpoints.

El canje responde con **una** tienda, y un restaurante con varias sucursales
llega como varias tiendas. Las demás llegan como sus propios eventos
`store.connected` y están en `GET /v1/stores`, así que arma tu lista de tiendas
conectadas con eso, nunca solo con el canje.

```ruby exchange_connection.rb theme={null}
# Trades the code an owner's approval produced for the store it connected. The
# code arrives on your redirect URI as `?code=pcc_...`, beside the `state` you
# sent when you asked for the link.
#
#   LISTO_API_KEY=lst_live_... LISTO_CONNECTION_CODE=pcc_... ruby exchange_connection.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")
CODE = ENV.fetch("LISTO_CONNECTION_CODE")

# 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/connections")

request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer #{API_KEY}"
request["Content-Type"] = "application/json"
request.body = JSON.generate({"code" => CODE})

# Safe to repeat. The same code answers with the same store for 24 hours from
# the approval, so an exchange whose response never reached you is retried
# rather than lost.
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. `code_expired` means the 24 hours
# are up, but the connection itself happened when the owner approved: read the
# store from GET /v1/stores (it was also sent to you as `store.connected`), and
# do not start a new connect request, which that account would now refuse.
# `not_found` is a code that does not exist for your key, or a test key: this
# flow is live only.
unless response.code == "201"
  abort("exchange refused: #{parse_error(response)["code"]}")
end

store = JSON.parse(response.body)

# The same store object `POST /v1/stores` answers with. Keep its `id`: it is the
# `store_id` for every delivery you send for this restaurant from now on.
puts "store #{store["id"]}"
puts "name: #{store["name"]}"

# A connected restaurant keeps its own console and its own balance, and the
# deliveries you create for it are paid from that balance.
puts "access_tier: #{store["access_tier"]}"
puts "billing: #{store["billing"]}"
```

```
store st_4bR8kM2wQ7xT1nV5cZ9pLdYe
name: La Taqueria del Centro
access_tier: full
billing: store
```

El código sirve 24 horas desde la aprobación. Es mucho más que el enlace, a
propósito: lo que el código tiene que aguantar es una llamada entre dos
servidores, que quizá haya que reintentar durante una caída de cualquiera de los
dos lados. Pasado eso es `400 code_expired`, pero la conexión en sí ocurrió
cuando el dueño aprobó: la tienda está en `GET /v1/stores` y su
`store.connected` se mandó en ese momento, así que tómala de ahí. No inicies una
nueva solicitud de conexión para ella, porque la cuenta ya está conectada.

**Canjear un código por segunda vez regresa la misma tienda.** No hay un rechazo
de "ya usado", así que un canje cuya respuesta nunca te llegó simplemente se
repite. Solo tu llave puede canjear tus códigos: cualquier otra recibe
`404 not_found` por uno.

## Qué es una tienda conectada

* **Está en `access_tier: "full"` y `billing: "store"`.** El restaurante
  conserva toda su consola, su saldo y su historial, y los envíos que creas para
  él se pagan de su propio saldo, como decía su pantalla de consentimiento. Los
  pedidos que despacha desde su propia consola te llegan como cualquier otro; ve
  [de dónde viene un envío](https://docs.listo.delivery/es/concepts/delivery-origin.md).
* **Desde la siguiente renovación del restaurante, su Plan Listo se te cobra a
  ti.** Hasta entonces sigue pagando su propio plan, y nada se cobra dos veces.
  Si no tenía un plan vigente, el plan empieza el día en que se conecta y ese mes
  se te cobra ese mismo día. Si su plan está sin pagar cuando se conecta, tu plan
  también empieza ese día: el mes que no había pagado se anula, así que no se le
  cobra a nadie, y ese mismo día se te cobra un mes completo. El bloque `plan`
  de la tienda dice cuál es; ve
  [plan y cobro](https://docs.listo.delivery/es/guides/plan-and-billing.md#un-restaurante-que-ya-paga-su-propio-plan).
  Si tu acuerdo hace que cada restaurante pague su propio plan, nada de esto
  aplica: el restaurante sigue pagando su propio plan, nada pasa a ti y no se te
  cobra nada; ve
  [cuando cada restaurante paga su propio plan](https://docs.listo.delivery/es/guides/plan-and-billing.md#cuando-cada-restaurante-paga-su-propio-plan).
* **Todas las sucursales se conectan a la vez.** El dueño da su consentimiento
  por la cuenta, no por un local, y nada en el flujo pregunta por sucursales. Un
  restaurante con tres sucursales se vuelve tres tiendas, cada una con su propio
  id `st_...`: el canje responde con una de ellas, cada una de las tres manda su
  propio `store.connected`, y todas están en
  [`GET /v1/stores`](https://docs.listo.delivery/es/api-reference/stores/list-your-stores) desde ese momento.
  Un canje no significa una sola tienda. Cada una lleva el nombre del negocio
  y de la sucursal, `Tortería La Mera Mera, Zapopan`, así que tu lista sigue
  leyéndose como un solo restaurante. Una sucursal cuyo nombre ya empieza con
  el del negocio, como `Tortería La Mera Mera Centro`, se publica tal cual.
* **No tiene `external_id`**, porque nunca mandaste uno. Ponle uno con
  [`PATCH /v1/stores/{id}`](https://docs.listo.delivery/es/api-reference/stores/update-a-store) si
  identificas tus tiendas con tus propios ids.
* **Conectar no es transferir.** Una cuenta que ya está conectada, contigo o con
  cualquier otro, no se puede volver a conectar, y esta API no tiene forma de
  mover una tienda de un socio a otro. Es a propósito: el restaurante cuyo saldo
  paga tendría que estar de acuerdo.

## Cuándo se rechaza

| HTTP | `code` | Desde | Qué pasó |
| - | - | - | - |
| 404 | `not_found` | los dos | Una llave de prueba. El flujo solo funciona con llaves reales. |
| 422 | `validation_failed` | `POST /v1/connect_requests` | Con `param: "redirect_uri"`: esa dirección no está registrada para tu empresa. |
| 422 | `validation_failed` | `POST /v1/connect_requests` | Con `param: "owner_email"`: ninguna cuenta propia de un restaurante entra con esa dirección, o la que entra ya está conectada, contigo o con cualquier otro. Casi siempre es un error de dedo, o un restaurante que no está en Listo, que en su lugar creas con `POST /v1/stores`. Si ya está conectada contigo, ya está en `GET /v1/stores` y no queda nada por hacer. |
| 422 | `validation_failed` | `POST /v1/connect_requests` | Con `param: "owner_email"`: esa dirección tiene acceso a más de una cuenta de Listo. Una persona puede ser dueña de varios negocios, y cuál conectas decide de qué saldo se pagan tus envíos, así que nunca se adivina, y una página de consentimiento que ya nombra un restaurante no es el lugar para preguntarlo. Contacta a Listo y la conexión se configura a mano. |
| 422 | `validation_failed` | los dos | Falta un campo o está mal formado. `error.param` lo nombra. |
| 404 | `not_found` | `POST /v1/connections` | Ese código no existe para tu llave. |
| 400 | `code_expired` | `POST /v1/connections` | Pasaron más de 24 horas desde la aprobación. La conexión ocurrió cuando el dueño aprobó: la tienda está en `GET /v1/stores` y su `store.connected` se mandó en ese momento. Tómala de ahí, y no inicies una nueva solicitud de conexión. |

Los dos rechazos de `owner_email` comparten código, y `error.message` los
distingue para quien lea tus logs. Ramifica según el código, y muéstrale el
mensaje a la persona que está configurando la conexión.


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