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 willPOST JSON to that URL with three headers that matter:
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.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 a400. Anyone can POST to your endpoint; the signature is
what separates Listo from them.
verify_webhook_signature.rb
Respond, then work
Answer2xx 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.
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. ReadingGET /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
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
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 endscanceled 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.