Some changes happen without a call from you: a hold runs out, a staging area closes, a rule changes. We tell your server about them with webhooks. Nothing is sent for what you did yourself, such as checking in or cancelling. You register a URL, and we send each event to it as a signed POST.
Create an endpoint
Tell us where to send events, and which ones. The URL has to be HTTPS.
curl -X POST https://api.golanoo.com/v1/webhook_endpoints \ -H "Authorization: Bearer $LANOO_API_KEY" \ -H "Idempotency-Key: 7d4e2b19-8a3c-4f6d-9e1b-5c0a3f8d2e71" \ -H "Content-Type: application/json" \ -d '{ "url": "https://dispatch.example.com/lanoo/events", "events": ["reservation.expired", "reservation.cancelled", "staging_area.closed"] }'{ "id": "we_3b91d0", "object": "webhook_endpoint", "url": "https://dispatch.example.com/lanoo/events", "events": ["reservation.expired", "reservation.cancelled", "staging_area.closed"], "secret": "whsec_6f1c0a…", "created_at": "2026-11-01T10:00:00Z"}The secret is in this response only. Store it somewhere safe straight away: you need it to check every event, and listing your endpoints does not show it again.
If the URL is not HTTPS or does not resolve, the call fails with url_invalid.
Send a test event
You do not have to wait for a real event to see your server work. Ask for a test event, and we send it straight away:
curl -X POST https://api.golanoo.com/v1/webhook_endpoints/we_3b91d0/ping \ -H "Authorization: Bearer $LANOO_API_KEY"It is an event of type ping, signed like every other event. If your server receives it and the signature checks out, you are ready for the real ones.
Change or remove an endpoint
To send events to a new URL, or to get other events than before, change the endpoint. Send only what changes:
curl -X PATCH https://api.golanoo.com/v1/webhook_endpoints/we_3b91d0 \ -H "Authorization: Bearer $LANOO_API_KEY" \ -H "Idempotency-Key: 2f8b6c1d-4e9a-4b7f-8c3d-1a5e9f0b6d24" \ -H "Content-Type: application/json" \ -d '{ "events": ["reservation.expired", "reservation.cancelled", "reservation.overstayed", "staging_area.closed"] }'The secret stays the same, and no event is lost on the way.
To stop receiving events, remove the endpoint with DELETE /webhook_endpoints/{id}. Nothing more is sent to it, including retries still waiting.
What an event looks like
Every event has the same shape. data.object is the reservation or staging area as it is after the change.
{ "id": "evt_1c7e55", "object": "event", "type": "reservation.cancelled", "created_at": "2026-11-02T13:55:00Z", "api_version": "2026-10-30", "data": { "object": { "id": "res_8f2c41", "object": "reservation", "vehicle_id": "rt-0417", "staging_area": "sa_norreport", "spot": "2", "location": { "lat": 55.6838, "lng": 12.5721 }, "status": "cancelled", "arrive_by": "2026-11-02T14:02:00Z", "held_until": "2026-11-02T14:07:00Z", "checked_in_at": null, "leave_by": null, "checked_out_at": null, "cancellation_reason": "staging_area_closed", "metadata": {}, "created_at": "2026-11-02T13:51:12Z" }, "alternatives": [ { "object": "availability", "staging_area": "sa_israels_plads", "name": "Israels Plads", "distance_m": 780, "free_spots": 1, "chance_free": 0.61 } ] }}api_version is the API version the event is written in. See Versions.
Event types and what to do
| Event | What happened | What to do |
|---|---|---|
reservation.expired | The vehicle did not check in before held_until. | Reserve again if the vehicle still needs a spot. |
reservation.cancelled | The staging area closed and gave up your hold. Not sent when you cancel yourself. | Send the vehicle elsewhere; see When a staging area closes. |
reservation.overstayed | A checked-in vehicle is past leave_by. Sent once. | Move the vehicle on, and check it out once it has left. |
reservation.must_leave | The staging area closed suddenly with your vehicle checked in. | Send the vehicle elsewhere now; see When a staging area closes. |
staging_area.updated | The staging area's rules, details or planned closures changed. | Update your copy of it, such as rules.max_stay_minutes or closures. |
staging_area.closed | The staging area stopped taking vehicles. | Stop reserving there. |
staging_area.reopened | It started taking vehicles again. | You can reserve there again. |
ping | You asked for a test event. | Nothing. It only shows that your server receives and checks events. |
For the reservation events, data.object is a reservation. For the staging area events, it is a staging area. For ping, it is your endpoint.
Check the signature
Every event carries a Lanoo-Signature header:
Lanoo-Signature: t=1793627701,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bdt is when we sent it, in Unix seconds. v1 is an HMAC-SHA256 of t, a full stop, and the raw request body, keyed with your endpoint's secret, written as hex.
To check it:
- Read
tandv1from the header. - Compute the HMAC-SHA256 of
<t>.<raw body>with your secret. - Compare it with
v1in constant time. There can be twov1s, while you swap secrets (see below). Accept the event if either matches. - Reject the event if
t, the time we sent it, is more than 5 minutes from your clock either way.
Use the raw body exactly as it arrived. If your framework parses the JSON first and you serialise it again, the bytes differ and the signature will not match.
import crypto from "node:crypto";
const TOLERANCE_SECONDS = 5 * 60;
export function verifyLanooSignature(rawBody, header, secret) { if (!header) return false; const parts = header.split(",").map((part) => part.split("=", 2)); const t = parts.find(([key]) => key === "t")?.[1]; const signatures = parts.filter(([key]) => key === "v1").map(([, value]) => Buffer.from(value, "hex")); if (!Number.isInteger(Number(t)) || signatures.length === 0) return false; if (Math.abs(Date.now() / 1000 - Number(t)) > TOLERANCE_SECONDS) return false;
const expected = crypto .createHmac("sha256", secret) .update(`${t}.`) .update(rawBody) .digest(); return signatures.some( (given) => given.length === expected.length && crypto.timingSafeEqual(given, expected), );}With Express, take the body raw on this one route:
app.post( "/lanoo/events", express.raw({ type: "application/json" }), (req, res) => { const ok = verifyLanooSignature( req.body, req.get("Lanoo-Signature"), process.env.LANOO_WEBHOOK_SECRET, ); if (!ok) return res.sendStatus(400);
const event = JSON.parse(req.body); enqueue(event); // your own queue; handle the event after responding res.sendStatus(200); },);Get a new secret
If your secret has leaked, or you just want to change it, ask for a new one:
curl -X POST https://api.golanoo.com/v1/webhook_endpoints/we_3b91d0/roll_secret \ -H "Authorization: Bearer $LANOO_API_KEY" \ -H "Idempotency-Key: 9c1e7a4b-3d6f-4a2e-b8c5-0f7d2e9a1b63" \ -H "Content-Type: application/json" \ -d '{ "expire_old_secret": "in_24_hours" }'The response carries the new secret, once, as when you created the endpoint. You choose when the old one stops:
now, if it has leaked. Events signed with the old one stop at once.in_24_hours, to swap it calmly. For a day, every event is signed with both secrets, so your server keeps working while you put the new one in place.
Respond quickly
Answer with any 2xx status as soon as you have checked the signature and stored the event. Do the work afterwards, off the request. Any other answer, or none, counts as a failed delivery.
Failed deliveries are retried with backoff. Removing an endpoint stops its retries too.
Expect duplicates and any order
The same event may arrive more than once, for example when your 2xx was lost on the way back. Each event keeps its id, such as evt_1c7e55, across deliveries. Record the ids you have handled, and skip an event whose id you have already seen.
Events can also arrive out of order. Do not assume the last one you received is the latest change.
Next
- Webhooks, every field of the endpoint and event objects.
- Checking in and out, for what to do on
reservation.overstayed.