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

# Receive and verify webhooks

> Subscribe to delivery events, verify their signature, and respond.

A delivery changes on its own: a courier is assigned, an estimate moves, a
customer pays, an order arrives. Webhooks are how you hear about it without
asking. Polling works, but a courier's position changes every twenty seconds
and no polling interval is both cheap and current.

## Subscribe an endpoint

Set the endpoint URL and its signing secret in the
[partner dashboard](https://partners.listo.delivery). Each mode has its own
pair, so your test events never arrive at your production endpoint and no
staging deploy can be told about a real order.

Listo will `POST` JSON to that URL with three headers that matter:

```http theme={null}
Content-Type: application/json
User-Agent: Listo-Webhooks/1.0 (+https://listo.delivery)
X-Listo-Signature: t=1753728063,v1=8f2c...
```

There is no `Authorization` header and no account id in the body. The signature
is the identification: it is what proves the request came from Listo and was
not altered on the way, and it is the only thing that does.

## The envelope

Every event has the same four fields, whatever its type.

```json theme={null}
{
  "id": "evt_9pQ2vX7mKdR4tYb1nZ3sLwCe",
  "type": "delivery.status_changed",
  "created_at": "2026-07-28T18:41:03Z",
  "data": {
    "id": "dlv_7hK2mQ9xR4tV6bN1cZ8sLwYe",
    "external_id": "order-841",
    "status": "en_route"
  }
}
```

For every type but `delivery.courier_location`, `data` is the whole object, the
same shape the REST endpoints answer with, so an event is directly comparable
with what you already stored. The `data` above is abridged; a real one carries
every field a delivery has.

| `type` | Fires when | Retried |
| - | - | - |
| `delivery.status_changed` | `status` moves, including a payment-driven move like `awaiting_payment` to `dispatching` | yes |
| `delivery.updated` | something visible changed without `status` moving: a courier's details arrive, an estimate shifts, `payment.status` changes, proof of delivery lands (`proof`, or a moment of `proofs`: the pickup, the door or the return), a charge posts to `charges` | yes |
| `delivery.courier_location` | the courier moved, roughly every 20 seconds. `data` is `{delivery_id, external_id, location, updated_at}`, not the full object | no |
| `withdrawal.updated` | a withdrawal resolves to `paid` or `failed` | yes |
| `store.updated` | a store's details change, its readiness to take card flips, or its [plan](https://docs.listo.delivery/en/guides/plan-and-billing.md) changes | yes |
| `store.connected` | the owner of a restaurant that already runs on Listo approved your connect request, so you have a new store. `data` is the whole store object | yes |

An event you ask for yourself with a [test webhook](https://docs.listo.delivery/en/guides/debugging.md#send-a-test-webhook)
carries a fifth field, `"test": true`, and describes objects that do not exist.
Real events never carry it.

`store.updated` is a signal rather than an answer: the store object carries no
card readiness field, so when you get one, re-read
[`GET /v1/stores/{id}/stripe_status`](https://docs.listo.delivery/api-reference/card-onboarding/read-a-stores-card-readiness).

`store.connected` fires the moment the owner approves, and it does not wait for
their browser: it can reach you before the browser comes back with the code, and
it still arrives when the browser never does. See
[connect a restaurant that already uses Listo](https://docs.listo.delivery/en/guides/connect-existing-account.md).

## Verify before you trust

Check the signature before parsing the body, and refuse anything that does not
check out with a `400`. Anyone can `POST` to your endpoint; the signature is
what separates Listo from them.

```ruby verify_webhook_signature.rb theme={null}
# Verifies the signature on an incoming Listo webhook before trusting a byte of
# it. Reads the raw request body on stdin, the way your web framework hands it
# to you, and refuses anything that does not check out.
#
#   LISTO_SIGNATURE='t=...,v1=...' LISTO_WEBHOOK_SECRETS=whsec_a,whsec_b \
#     ruby verify_webhook_signature.rb < body.json
#
require "json"
require "openssl"

# The window a signature stays good for, matching the one Listo signs against.
# It is what stops a request someone captured from being replayed at you later.
TOLERANCE_SECONDS = 300

# `secrets` is a LIST because a rotation has two secrets live at once: accept
# either while you swap over, then drop the old one.
def listo_signature_valid?(payload, header, secrets, now: Time.now)
  match = /\At=(\d+),v1=(\h+)\z/.match(header.to_s)
  return false if match.nil?

  timestamp = match[1].to_i
  signature = match[2]

  # Checked in BOTH directions. A timestamp far in the past is the replay this
  # scheme exists to stop; one far in the future is the same attack against a
  # clock nobody looked at.
  return false if (now.to_i - timestamp).abs > TOLERANCE_SECONDS

  secrets.any? do |secret|
    expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{payload}")
    OpenSSL.secure_compare(expected, signature)
  end
end

# The RAW bytes, before any JSON parsing. Parsing and re-serializing changes
# them (key order, escapes, whitespace) and the signature covers what was sent,
# not what your JSON library would have sent.
payload = $stdin.read
header = ENV.fetch("LISTO_SIGNATURE", nil)
secrets = ENV.fetch("LISTO_WEBHOOK_SECRETS").split(",")

unless listo_signature_valid?(payload, header, secrets)
  # Answer 400 and stop. Do not parse it, do not act on it, do not log it as an
  # event you received.
  abort("signature rejected")
end

event = JSON.parse(payload)

# Events arrive at least once and in no guaranteed order, so treat `id` as the
# thing you deduplicate on and the object's own timestamps as the thing you
# order by. Answer 2xx quickly and do the work afterwards.
puts "#{event["type"]} #{event["id"]}"

case event["type"]
when "delivery.status_changed", "delivery.updated"
  delivery = event["data"]
  puts "order #{delivery["external_id"]} is #{delivery["status"]}"
when "delivery.courier_location"
  location = event.dig("data", "location")
  puts "courier at #{location["lat"]},#{location["lng"]}"
else
  # A new type is an additive change, so ignore what you do not recognise
  # rather than failing on it.
  puts "ignored"
end
```

Three details in there are load-bearing, and
[the signing page](https://docs.listo.delivery/en/concepts/signing.md) explains why each one is: the
raw body is what gets hashed, the timestamp is checked in both directions, and
the check runs against a *list* of secrets so a rotation does not drop events.

## Respond, then work

Answer `2xx` as soon as the signature checks out and do the real work
afterwards. Listo waits 5 seconds for a response and does not follow redirects,
so a slow endpoint reads as a failed delivery and a redirect reads as an error.

Three properties to build against:

* **At least once.** The same event can arrive twice. Deduplicate on `id`,
  which is stable across every redelivery of that event.
* **Unordered.** Two events sent close together can arrive in either order.
  Order by the object's own timestamps rather than by arrival, and treat a
  status you have already passed as old news rather than a regression.
* **Additive.** New event types and new fields can appear within v1. Ignore
  what you do not recognise instead of failing on it.

Anything other than a `2xx` is retried with growing gaps, about six attempts in
total. A `delivery.courier_location` is never retried: another position is
already on its way, and resending a stale one would put your map further behind
rather than catch it up.

## When you missed some

If your endpoint was down, read the deliveries back: they are the record, and
the events were only ever a notification about them. A resent event cannot do
this for you, because its body is the snapshot from the moment the event
happened, so a day-old status change tells you about a state the order has long
since left. Reading `GET /v1/deliveries` always gives you where things stand now.

To see what you missed, or to send one event again while you fix a handler, see
[debugging your integration](https://docs.listo.delivery/en/guides/debugging.md): you can list every
event and every attempt to deliver it, resend one event, and send a test event
of any type.

```ruby reconcile_deliveries.rb theme={null}
# Walks every delivery created since a given moment, one page at a time. This
# is the recovery path when your endpoint was down and events were missed:
# you re-read the deliveries themselves, because a resent event is only the
# snapshot from when it happened and a delivery is where things stand now.
#
#   LISTO_API_KEY=lst_test_... LISTO_SINCE=2026-07-29T00:00:00Z \
#     ruby reconcile_deliveries.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")
SINCE = ENV.fetch("LISTO_SINCE")

# 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 get(path, query)
  uri = URI("#{BASE_URL}#{path}")
  uri.query = URI.encode_www_form(query)

  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.
  abort("GET #{path} refused: #{parse_error(response)["code"]}") unless response.code == "200"

  JSON.parse(response.body)
end

# Every list on this API answers the same shape: `data`, `has_more`, and an
# opaque `cursor`. Keep passing the cursor back until `has_more` is false. The
# cursor is a string with no meaning of its own, so never parse or build one.
cursor = nil
count = 0

loop do
  query = {"created_after" => SINCE}
  query["cursor"] = cursor if cursor

  page = get("/v1/deliveries", query)

  page["data"].each do |delivery|
    count += 1
    puts "#{delivery["external_id"]} #{delivery["status"]}"
  end

  break unless page["has_more"]

  cursor = page["cursor"]
end

puts "#{count} deliveries since #{SINCE}"
```

That is also the shape of every list on this API: a `data` array, a `has_more`
boolean, and an opaque `cursor` you hand back to get the next page. Pages hold
up to 50 rows and come newest first. The cursor is a string with no meaning of
its own, so never build one or parse one.

Money has a record of its own in exactly the same way. `GET /v1/ledger` lists
every movement on your balance, so a period can be reconciled without any of the
events that announced it. See
[balance and withdrawals](https://docs.listo.delivery/en/guides/balance-and-withdrawals.md).

## Statuses worth reacting to

```
                 (card, and the customer owes something)
        .-> awaiting_payment -----.
        |          |              |
        |          v              |
        +-> scheduled ------------+
        |                         |
create -+-------------------------+-> dispatching -> courier_assigned -> picking_up -> en_route -> delivered
                                            |                                              |
                                            v                                              v
                                        no_courier                                     returning -> returned
```

Most orders take the bottom line. A delivery with no `pickup_ready_at` and
nothing for Listo to collect from the customer, which is every immediate cash
and prepaid order, is **born at `dispatching`** and never passes through
`scheduled`. `scheduled` means a future pickup and nothing else, and a card
order reaches it too if it is paid before its window opens.

`awaiting_location` is an order the store took through Listo's console or its
WhatsApp assistant whose drop-off pin Listo does not trust yet. It is held, with
no courier requested, until the customer confirms the location (on a map link or
by sharing it in the WhatsApp chat) or the store confirms it from its console,
and then it moves on to `scheduled` or
`dispatching`. It can also end in `no_courier`, when a courier cannot be
requested for that location (it is outside where the store delivers, or the
store itself cannot request a courier right now), or in `canceled`. A card order
reaches it once it is paid. A delivery you create through the API is never in
it, so you only see it on stores whose owners use the console or the assistant.

`returning` is a live delivery, not a final one: the drop-off failed and the
courier is bringing the order back to the store. It ends in `returned` once the
order is back. The charge for the return is already in `charges` when it begins,
and courier positions keep arriving.

`delivered`, `canceled` and `returned` are final. `canceled` is reachable from
anywhere before that.

`no_courier` is the one dead end you can get out of: nobody took the order.
Retry it with
[`POST /v1/deliveries/{id}/retries`](https://docs.listo.delivery/api-reference/deliveries/retry-a-failed-dispatch)
or cancel it, and read `failure_reason` to decide which. That field is one of
four values, bucketed by whose side the problem is on:

| `failure_reason` | Means |
| - | - |
| `no_couriers_available` | Supply. Nobody was free, or nobody near that address was. This is the one worth retrying. |
| `address_unreachable` | The courier could not get to the address, find it, or leave the order safely. |
| `customer_unavailable` | The handoff could not happen: nobody there, unreachable, or refusing. |
| `delivery_failed` | Something else. The honest catch-all. |

A `returned` delivery is the same delivery throughout: the order came back to
the store rather than becoming a second order, and courier positions keep
arriving during the return leg. The `delivery.status_changed` event for `returning` carries the store's return
code, which your system has to show to the store's staff (see
[the return code](https://docs.listo.delivery/en/guides/quote-and-create.md#when-an-order-comes-back-the-return-code)).

On a cancellation, `cancellation.party` says who called it off: `partner` for
your side of the order, `courier` for the person carrying it, and `platform` for
everything else Listo attributes to itself. `cancellation.reason` and
`cancellation.description` carry the rest.

> [!NOTE]
>
> **What is never in a payload**
>
> Delivery events carry your customer's name, phone and address, because you gave
> them to Listo and the courier needs them. Nothing else rides along: no card
> details, no internal identifiers, and nothing naming who fulfils the trip.
> Statuses, errors, tracking links and support references are Listo's own
> vocabulary end to end.

## The terminal status is not always the last event

A delivery that ends `canceled` or `returned` can cost money after it ends. A
cancellation late enough that a courier was already working, or a return trip
after a failed dropoff, is billed to you at the delivery fee for each leg that
was driven, and that charge is only settled once the cancellation or the return
itself has been. That is normally later than the status change that caused it,
sometimes by a good while.

Sometimes that is settled with the cancellation itself, and the terminal
`delivery.status_changed` already arrives with `charges` filled in. Sometimes it
is not, and the charge posts afterwards, on a delivery whose final status event
you handled a while ago. Then it comes as a `delivery.updated`:

```json theme={null}
{
  "id": "evt_4mQ8vX2nR7tKdY6b1nZ3sLwC",
  "type": "delivery.updated",
  "created_at": "2026-08-01T18:41:03Z",
  "data": {
    "id": "dlv_7hK2mQ9xR4tV6bN1cZ8sLwYe",
    "external_id": "order-841",
    "status": "returned",
    "charges": [
      {
        "type": "return_fee",
        "amount_cents": 18560,
        "charged_at": "2026-08-01T18:41:03Z"
      }
    ]
  }
}
```

`charges` is on every delivery and is `[]` until something posts, which for
almost every order is forever. `type` is `cancellation_fee` or `return_fee`, and
`amount_cents` is what you were billed for it: the delivery fee for each leg that
was driven, less your own store markup. It is a field of the delivery
object like any other, so it is on every response that carries one:
[`GET /v1/deliveries/{id}`](https://docs.listo.delivery/api-reference/deliveries/fetch-a-delivery) has the
full list.

You are told once either way: a charge the terminal event already carried does
not get a second event of its own. What you cannot do is assume which of the two
carried it, so read `charges` off whichever event you are handed.

Treat the array as the current truth about that delivery rather than as a list
of new charges since the last event, and key your own billing on the delivery
id. Events arrive at least once, so the same populated `charges` can reach you
twice, and a consumer that adds up what each event carried bills the same trip
again. `delivery.courier_location` is the one event that never carries any of
this: its body is the slim position object, with no delivery in it at all.

> [!WARNING]
>
> **If you bill on the terminal event, handle the late one**
>
> Treating `delivery.status_changed` to `canceled` or `returned` as the end of the
> order is the natural way to build this, and it is right on every order whose
> cost was already settled. It goes wrong on the rest: `charges` is empty at that
> moment and fills in later, so a bill written from it is short by the whole
> charge. Either keep the order open for a later `delivery.updated`, or read
> the delivery back with `GET /v1/deliveries/{id}` before you invoice.
>
> If your books are already closed by then, close them from the
> [ledger](https://docs.listo.delivery/en/guides/balance-and-withdrawals.md) instead: `GET /v1/ledger`
> carries the same charge as a `provider_charge` row against that delivery.


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