Skip to main content
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. 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:
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.
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. An event you ask for yourself with 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. 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.

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.
verify_webhook_signature.rb
Three details in there are load-bearing, and the signing page 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: you can list every event and every attempt to deliver it, resend one event, and send a test event of any type.
reconcile_deliveries.rb
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.

Statuses worth reacting to

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 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: 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). 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.
What is never in a payloadDelivery 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:
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} 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.
If you bill on the terminal event, handle the late oneTreating 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 instead: GET /v1/ledger carries the same charge as a provider_charge row against that delivery.