Guides

Receiving webhooks

Register an endpoint, test it, check each event's signature, and act on expired holds, cancellations, overstays and staging area changes.

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
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"]
}'
JSON
{
"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
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
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.

JSON
{
"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

EventWhat happenedWhat to do
reservation.expiredThe vehicle did not check in before held_until.Reserve again if the vehicle still needs a spot.
reservation.cancelledThe 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.overstayedA checked-in vehicle is past leave_by. Sent once.Move the vehicle on, and check it out once it has left.
reservation.must_leaveThe staging area closed suddenly with your vehicle checked in.Send the vehicle elsewhere now; see When a staging area closes.
staging_area.updatedThe staging area's rules, details or planned closures changed.Update your copy of it, such as rules.max_stay_minutes or closures.
staging_area.closedThe staging area stopped taking vehicles.Stop reserving there.
staging_area.reopenedIt started taking vehicles again.You can reserve there again.
pingYou 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:

Text
Lanoo-Signature: t=1793627701,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

t 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:

  1. Read t and v1 from the header.
  2. Compute the HMAC-SHA256 of <t>.<raw body> with your secret.
  3. Compare it with v1 in constant time. There can be two v1s, while you swap secrets (see below). Accept the event if either matches.
  4. 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.

JavaScript
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:

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