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

# Signing

> How webhook signatures are built and how to verify them.

Listo sends webhooks to a URL you gave it, with no credential in the request.
The signature is the credential. It is what tells you an event came from Listo,
arrived unaltered, and is not one somebody captured earlier and sent again.

Every event carries one header:

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

* `t` is the Unix time, in seconds, at which the event was signed.
* `v1` is a hex HMAC-SHA256, keyed on your signing secret, over the string
  `"<t>.<raw body>"`.

Note that `t` is *inside* the signed string, not merely sitting beside it. That
is the whole point of this scheme over a plain HMAC of the body: a plain one
stays valid forever, so anyone who ever saw a single valid request could resend
it whenever they liked. Binding the timestamp into the digest means a replay
has to either keep the original `t`, and fall outside the window below, or
change it, and invalidate the signature.

## Verifying one

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

Four rules, and each of them is a way the check can be wrong while still looking
right.

**Hash the raw bytes.** Sign what arrived on the wire, before any JSON parsing.
Parsing and re-serialising changes the bytes, because your JSON library has its
own ideas about key order, escape sequences and whitespace, and the signature
covers what was sent rather than what your library would have sent. Most web
frameworks give you the raw body if you ask; find out how yours does before
writing the check.

**Check the timestamp in both directions.** Reject anything more than five
minutes old, which is the replay this scheme exists to stop, *and* anything more
than five minutes in the future. Skipping the second half hands an attacker an
arbitrarily long window by simply choosing a large `t`.

**Compare in constant time.** Use your language's constant-time comparison, not
`==`. A comparison that returns early on the first differing byte leaks how much
of a guess was right, which is enough to walk out a valid signature one byte at
a time.

**Fail closed.** A malformed header, a missing header, an unparseable timestamp
and a signature that does not match are all the same answer: not authentic.
Reject with a `400`, and do not parse or act on the body. Never let the verifier
raise; an exception in a verifier is a `500` where a refusal belonged.

## Rotating a secret

You can have two secrets live at once, which is what makes a rotation possible
without dropping events. Listo signs with the primary one; you accept either.

1. **Add a second secret** in the [partner dashboard](https://partners.listo.delivery).
   Nothing changes yet: Listo keeps signing with the first.
2. **Teach your verifier both.** This is why the check above takes a *list*:
   accept an event that validates under either secret.
3. **Promote the new secret.** Listo starts signing with it. Your endpoint keeps
   working through the switch, because it already accepts both.
4. **Drop the old one**, in the dashboard and in your own configuration.

Do them in that order. Promote before step 2 and every event in flight is
rejected by an endpoint that has never seen the new secret.

> [!WARNING]
>
> **A secret is a secret**
>
> Store signing secrets the way you store passwords: server side, out of your
> repository, out of anything a browser can reach. Anyone holding one can forge an
> event your verifier will accept, which is the same as being able to tell your
> system that an order was delivered.

## Modes have their own secrets

Live and test each have their own endpoint URL and their own signing secret. A
secret from one mode never validates an event from the other, which is what
stops a staging deploy from being able to accept production traffic. Rotate them
independently.

Putting all of this to work, including responding, deduplicating and retries, is
[receive and verify webhooks](https://docs.listo.delivery/en/guides/webhooks.md).


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