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

# Firmas

> Cómo se construye la firma de un webhook, y cómo verificarla.

Listo manda los webhooks a una URL que tú le diste, sin ninguna credencial en la
petición. La firma es la credencial. Es lo que te dice que un evento vino de
Listo, llegó sin alteraciones, y no es uno que alguien capturó antes y volvió a
mandar.

Cada evento lleva un encabezado:

```http theme={null}
X-Listo-Signature: t=1753728063,v1=8f2c9d4e...
```

* `t` es la hora Unix, en segundos, en la que se firmó el evento.
* `v1` es un HMAC-SHA256 en hexadecimal, con tu secreto de firma como llave,
  sobre la cadena `"<t>.<raw body>"`: el valor de `t`, un punto, y el cuerpo de
  la petición tal como llegó.

Fíjate en que `t` va *dentro* de la cadena que se firma, no nada más al lado.
Ahí está toda la diferencia contra un HMAC simple del cuerpo: uno simple sigue
siendo válido para siempre, así que cualquiera que haya visto una sola petición
válida podría reenviarla cuando se le antojara. Meter la marca de tiempo dentro
del resumen obliga a que una repetición o conserve el `t` original, y caiga
fuera de la ventana de abajo, o lo cambie, e invalide la firma.

## Cómo verificar una

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

Cuatro reglas, y cada una es una forma de que la verificación esté mal mientras
aparenta estar bien.

**Firma los bytes tal como llegaron.** Sobre lo que llegó por el cable, antes de
cualquier parseo de JSON. Parsear y volver a serializar cambia los bytes, porque
tu librería de JSON tiene sus propias ideas sobre el orden de las llaves, las
secuencias de escape y los espacios, y la firma cubre lo que se mandó, no lo que
tu librería habría mandado. Casi cualquier framework web te da el cuerpo sin
procesar si se lo pides; averigua cómo lo hace el tuyo antes de escribir la
verificación.

**Revisa la marca de tiempo en las dos direcciones.** Rechaza cualquier cosa con
más de cinco minutos de antigüedad, que es la repetición que este esquema existe
para frenar, *y* cualquier cosa con más de cinco minutos hacia el futuro.
Saltarte la segunda mitad le regala a un atacante una ventana arbitrariamente
larga con solo escoger un `t` grande.

**Compara en tiempo constante.** Usa la comparación de tiempo constante de tu
lenguaje, no `==`. Una comparación que se corta en el primer byte distinto
filtra qué tanto acertó el intento, y con eso basta para ir sacando una firma
válida byte por byte.

**Falla cerrado.** Un encabezado mal formado, un encabezado ausente, una marca
de tiempo que no se puede leer y una firma que no coincide son todos la misma
respuesta: no es auténtico. Rechaza con un `400`, y no parsees ni actúes sobre
el cuerpo. Nunca dejes que el verificador lance una excepción; una excepción en
un verificador es un `500` donde iba un rechazo.

## Cómo rotar un secreto

Puedes tener dos secretos vivos al mismo tiempo, y eso es lo que hace posible
una rotación sin perder eventos. Listo firma con el principal; tú aceptas
cualquiera de los dos.

1. **Agrega un segundo secreto** en el
   [panel de socios](https://partners.listo.delivery). Todavía no cambia nada:
   Listo sigue firmando con el primero.
2. **Enséñale los dos a tu verificador.** Por eso la verificación de arriba
   recibe una *lista*: acepta un evento que valide con cualquiera de los dos.
3. **Promueve el nuevo secreto.** Listo empieza a firmar con él. Tu endpoint
   sigue funcionando durante el cambio, porque ya acepta los dos.
4. **Quita el viejo**, en el panel y en tu propia configuración.

Hazlo en ese orden. Si promueves antes del paso 2, cada evento en vuelo lo
rechaza un endpoint que nunca ha visto el secreto nuevo.

> [!WARNING]
>
> **Un secreto es un secreto**
>
> Guarda los secretos de firma como guardas las contraseñas: del lado del
> servidor, fuera de tu repositorio, fuera de cualquier cosa que alcance un
> navegador. Quien tenga uno puede falsificar un evento que tu verificador va a
> aceptar, que es lo mismo que poder decirle a tu sistema que un pedido se
> entregó.

## Cada modo tiene sus propios secretos

El mundo real y el de prueba tienen cada uno su URL de endpoint y su secreto de
firma. Un secreto de un modo nunca valida un evento del otro, y eso es lo que
impide que un despliegue de staging pueda aceptar tráfico de producción. Rótalos
por separado.

Poner todo esto a trabajar, incluyendo cómo responder, cómo deduplicar y cómo
funcionan los reintentos, es
[recibe y verifica webhooks](https://docs.listo.delivery/es/guides/webhooks.md).


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