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

# Connect a restaurant that already uses Listo

> Ask the owner to connect the Listo account they already have, instead of creating a second one.

Most stores you add are new to Listo, and you create them with
[`POST /v1/stores`](https://docs.listo.delivery/api-reference/stores/create-a-store). Some restaurants
already run on Listo with an account of their own: their own console, their own
balance, their own delivery history. You do not create a store for one of those.
You ask its owner to connect the account they already have, they approve it on a
Listo page, and you get the store.

Nothing is created along the way. What you end up holding is an ordinary store,
and every other endpoint treats it exactly like one you created.

> [!WARNING]
>
> **Live keys only**
>
> This flow works with a live key and nothing else. A test key gets
> `404 not_found` from both endpoints on this page, because there is no test
> version of a real restaurant's real account. What you rehearse in test mode is
> `POST /v1/stores`, which is what runs whenever the owner has no account of their
> own.

## Create it, or connect it

`owner_email` on `POST /v1/stores` is how you find out which of the two you are
doing. It is a check rather than a field of the store: send the address of
whoever runs the location, and before anything is written Listo checks whether
that address already signs in to a restaurant's own Listo account.

* **There is none.** The store is created, and the path from there is the usual
  one: set its `access_tier` (see [the money model](https://docs.listo.delivery/en/concepts/money.md))
  and, if somebody at the restaurant is to sign in,
  [give them a login](https://docs.listo.delivery/en/guides/balance-and-withdrawals.md#giving-a-store-owner-a-login).
* **There is one.** The create answers `409 account_exists` and nothing is
  created. A second account for one kitchen would split its balance, its console
  and its history in two. That `409` is the way into this page.

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

Nothing stores the address and no store object echoes it back. It only decides
which of the two paths you take.

## Register your redirect URI first

When the owner decides, Listo sends their browser back to you, and only ever to
an address you registered. Add your callback on the **Redirect URIs** page of
the [partner dashboard](https://partners.listo.delivery) before your first
request.

* **The whole string is compared.** Register the full callback URL, not only
  the origin. There are no wildcards and no prefixes, so
  `https://partner.example.com/listo/callback` does not also allow
  `https://partner.example.com/listo/callback/done`.
* **It must be `https://`**, except on `localhost` and `127.0.0.1`, where
  `http://` is allowed so you can build against your own machine.
* **The list belongs to your company, not to a mode**, so it does not change
  when you switch between live and test in the dashboard. Register your
  production callback and the one you develop against.
* **An address that is not on the list is refused when you ask for the link**,
  with `422 validation_failed` and `param: "redirect_uri"`. It fails on your own
  server, before any restaurant is shown anything.

## Ask for the link

[`POST /v1/connect_requests`](https://docs.listo.delivery/api-reference/account-connection/ask-a-restaurant-to-connect-its-account)
takes the owner's address, one of your registered redirect URIs, and optionally a
`state` of your own:

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

It answers `201` with a link and a deadline:

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

That is everything it tells you. Listo works out which restaurant the address
belongs to and keeps that to itself: you learn which account you connected only
if the owner approves.

The link is good for 30 minutes from when you ask for it. Minting another is
free, so an expired link is a button to render again, not an error to handle.
Ask for it when the owner is about to use it rather than hours ahead.

`state` is yours: any value up to 255 characters, which Listo never reads and
sends back untouched. It is how you tie the browser that comes back to the
session that left.

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

The link carries a token that opens the owner's consent page, which is why the
sample prints only its host. Send it to the owner and nowhere else, and keep it
out of your logs.

## What the owner sees

Send the owner's browser to `url`, with a redirect, a link or a button in your
product. Everything from there happens on Listo. This is what they read, in the
words on their screen, so that you and the restaurant are looking at the same
sentences if they call you.

**Signing in.** Unless that browser is already signed in to their Listo
console, they land on a page titled "Connect your account", which reads "Sign in
to connect *their restaurant* with *your company*". They type the email
and password they already use for Listo and press **Sign in and continue**. There is
no sign-up here: the page's only other way out is "No Listo account? Go back to
*your company*", which brings them back to you with `error=access_denied`.

**The consent screen.** It is titled "*Your company* wants to connect to your
account" and reads "Authorize the connection so *your company* can send and
manage deliveries with your Listo account." It shows their restaurant and the
address they signed in as, then what they are granting, under "When you connect,
*your company* will be able to:"

* Create deliveries on behalf of your business
* See your deliveries and their status
* Check your delivery balance

It closes with "Your deliveries are still paid from your Listo balance, just as
they are today." and two buttons, **Allow connection** and **Cancel**.

When the restaurant has more than one branch, the consent screen also lists
every branch that will connect, because approving connects all of them at once
(see [what a connected store is](#what-a-connected-store-is)).

Both names on those pages come from Listo, never from your request: *your
company* is your company's name as Listo has it on your partner account, and
*their restaurant* is the name of the restaurant's business in Listo, the account
as a whole rather than one of its branches. No field on this API puts words on
those pages.

Only an owner of the account can approve, and it does not have to be the person
whose address you sent: that address is only how Listo found the account. A
member of staff who signs in is told "Only an owner can authorize this
connection."

A link that expired or was cancelled opens a page titled "This request is no
longer valid"; a link the owner already approved opens one titled "This
connection is already approved". Both have a button back to you, and that button
brings them back to you with the outcome that link already had.

## The browser comes back

When the owner decides, Listo sends their browser to your `redirect_uri`:

| The owner | Their browser arrives at |
| - | - |
| Approved | `https://partner.example.com/listo/callback?code=pcc_...&state=sess-8f21` |
| Cancelled | `https://partner.example.com/listo/callback?error=access_denied&state=sess-8f21` |

`error=access_denied` means nothing was connected and that link is finished,
whether the owner pressed **Cancel** on the consent screen, left from the
sign-in page, or opened a link that was no longer valid. It cannot be reopened,
so if the owner changes their mind, ask for a new one. If the owner reopens a
link they already approved, their browser comes back to you with the same `code`
again; exchanging it twice is safe.

Listo does not email the owner about it either way. If you want to confirm the
connection to them, that message is yours to send.

`state` comes back exactly as you sent it, on both. Check it against the value
you stored for that session before you act on the redirect, and ignore one whose
`state` you do not recognise: it is your protection against a forged callback. If
you sent no `state`, none comes back. If the URI you registered already carries
a query string of its own, these parameters are added to it.

## The webhook does not wait for the browser

When the owner approves, a
[`store.connected`](https://docs.listo.delivery/api-reference/webhooks/a-restaurant-connected-its-existing-account-to-you) event goes to your
live webhook endpoint, and `data` is the whole store object, `id` included. It
does not depend on the owner's browser, so it can reach you before the redirect
does, and it still reaches you when the redirect never does: an owner can
approve on a phone and close the tab before their browser gets back to you.

Treat the store as connected when either one reaches you, whichever is first, and
deduplicate on the event `id` as with every event. Setting up the endpoint is
[receiving webhooks](https://docs.listo.delivery/en/guides/webhooks.md).

## Exchange the code

[`POST /v1/connections`](https://docs.listo.delivery/api-reference/account-connection/exchange-an-approval-for-the-store) takes the
`code` from the redirect:

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

It answers `201` with the store object, exactly the shape `POST /v1/stores`
returns, `st_...` id included. From then on it is an ordinary store on every
other endpoint.

The exchange answers with **one** store, and a restaurant with several branches
arrives as several stores. The others come as their own `store.connected`
events and are on `GET /v1/stores`, so take your list of connected stores from
those, never from the exchange alone.

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

The code is good for 24 hours from the approval. That is far longer than the
link on purpose: what the code has to survive is a call between two servers,
which may need retrying through an outage on either side. Past that it is
`400 code_expired`, but the connection itself happened when the owner approved:
the store is on `GET /v1/stores` and its `store.connected` was sent then, so
read it from there. Do not start a new connect request for it, because the
account is already connected.

**Exchanging a code a second time returns the same store again.** There is no
"already used" refusal, so an exchange whose response never reached you is
simply repeated. Only your key can exchange your codes: any other key gets
`404 not_found` for one.

## What a connected store is

* **It is on `access_tier: "full"` and `billing: "store"`.** The restaurant
  keeps its whole console, its balance and its history, and the deliveries you
  create for it are paid from its own balance, as its consent screen said. Orders
  it dispatches from its own console reach you like any other; see
  [where a delivery came from](https://docs.listo.delivery/en/concepts/delivery-origin.md).
* **From the restaurant's next renewal, its Plan Listo is charged to you.** Until
  then it keeps paying its own plan, and nothing is charged twice. If it had no
  live plan, the plan starts the day it connects and that month is charged to
  you that day. If its plan is unpaid when it connects, your plan starts that
  day too: the month it had not paid is voided, so nobody is charged for it,
  and one full month is charged to you that day. The store's `plan` block says
  which it is; see
  [plan and billing](https://docs.listo.delivery/en/guides/plan-and-billing.md#a-restaurant-that-already-pays-its-own-plan).
  If your agreement has each restaurant pay its own plan, none of this applies:
  the restaurant keeps paying its own plan, nothing moves to you and nothing is
  charged to you; see
  [where each restaurant pays its own plan](https://docs.listo.delivery/en/guides/plan-and-billing.md#where-each-restaurant-pays-its-own-plan).
* **Every branch connects at once.** The owner consents for the account, not for
  one location, and nothing in the flow asks about branches. A restaurant with
  three branches becomes three stores, each with its own `st_...` id: the
  exchange answers with one of them, each of the three sends its own
  `store.connected`, and all of them are on
  [`GET /v1/stores`](https://docs.listo.delivery/api-reference/stores/list-your-stores) from that moment.
  One exchange does not mean one store. Each of them is named after the
  business and the branch, `Tortería La Mera Mera, Zapopan`, so your list
  still reads as one restaurant. 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.
* **It has no `external_id`**, because you never sent one. Set one with
  [`PATCH /v1/stores/{id}`](https://docs.listo.delivery/api-reference/stores/update-a-store) if you key
  your stores on your own ids.
* **Connecting is not a transfer.** An account that is already connected, to you
  or to anybody else, cannot be connected again, and this API has no way to move
  a store from one partner to another. That is deliberate: the restaurant
  whose balance pays would have to agree to it.

## When it is refused

| HTTP | `code` | From | What happened |
| - | - | - | - |
| 404 | `not_found` | both | A test key. The flow is live only. |
| 422 | `validation_failed` | `POST /v1/connect_requests` | With `param: "redirect_uri"`: that address is not registered for your company. |
| 422 | `validation_failed` | `POST /v1/connect_requests` | With `param: "owner_email"`: no restaurant's own Listo account signs in with that address, or the one that does is already connected, to you or to anybody else. Usually a typo, or a restaurant that is not on Listo, which you create with `POST /v1/stores` instead. If it is already connected to you, it is on `GET /v1/stores` and there is nothing left to do. |
| 422 | `validation_failed` | `POST /v1/connect_requests` | With `param: "owner_email"`: that address signs in to more than one Listo account. One person can own several businesses, and which one you connect decides whose balance pays for your deliveries, so it is never guessed, and a consent page that already names one restaurant is not the place to ask. Contact Listo and the connection is set up by hand. |
| 422 | `validation_failed` | both | A field is missing or malformed. `error.param` names it. |
| 404 | `not_found` | `POST /v1/connections` | That code does not exist for your key. |
| 400 | `code_expired` | `POST /v1/connections` | More than 24 hours since the approval. The connection happened when the owner approved: the store is on `GET /v1/stores` and its `store.connected` was sent then. Read it from there, and do not start a new connect request. |

The two `owner_email` refusals share a code, and `error.message` tells them
apart for whoever reads your logs. Branch on the code, and show the message to
the person setting up the connection.


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