Webhooks
Guides
Webhooks
Signed Muvon API events: types, payload, retries and how to verify Muvon-Signature in Node.
In the partners' portal you save one https URL per mode (test and live). Each has its own whsec_… secret, shown once. The test button sends a ping event.
Event types
| Type | When |
|---|---|
booking.created | A booking was created. |
booking.paid | Your customer paid. |
booking.assigned … booking.completed | Each ride status. See Booking lifecycle. |
booking.no_show | The passenger didn't show up. |
booking.cancelled | It was cancelled: by you, by Muvon or for non-payment. |
ping | Test from the portal. Carries no booking. |
Payload
A POST with Content-Type: application/json and the Event object, carrying the full booking as it ended up:
{
"id": "evt_0a9b8c7d6e5f4a3b",
"object": "event",
"type": "booking.paid",
"created": "2026-11-14T18:22:41-05:00",
"livemode": false,
"data": {
"object": {
"id": "bk_5d2e8a41c07f4b9e",
"object": "booking",
"kind": "transfer",
"code": "TEST-000001",
"livemode": false,
"status": "new",
"payment_status": "paid",
"payment_url": "https://api.muvon.mx/pay/r/3c9f0b7e6a5d4c2b1a09f8e7d6c5b4a3",
"hold_expires_at": null,
"external_ref": "ORDER-5521",
"service_type": "airport_pickup",
"pickup_at": "2026-12-20T14:30:00-05:00",
"pickup": {
"place_id": "pl_cun",
"name": "Cancun Airport (CUN)",
"address": null
},
"dropoff": {
"place_id": "pl_hotel52",
"name": "Hotel 52 Playa del Carmen",
"address": null
},
"hours": null,
"vehicle_type_id": "vt_sedan",
"passengers": {
"adults": 2,
"children": 1
},
"bags": 3,
"extras": [
{
"code": "BOOSTER_SEAT",
"quantity": 1,
"price_cents": 1000
}
],
"flight_number": "AA1234",
"room": null,
"notes": "Viajamos con una carriola",
"passenger": {
"first_name": "Ana",
"last_name": "García",
"email": "ana@example.com",
"phone": "+5215512345678",
"language": "es"
},
"price_cents": 9500,
"currency": "USD",
"commission_cents": 950,
"tracking_url": "https://api.muvon.mx/track?t=a1b2c3d4e5f6",
"cancel_reason": null,
"created_at": "2026-11-14T18:10:00-05:00",
"updated_at": "2026-11-14T18:22:41-05:00"
}
}
}Verify the signature
Each delivery carries Muvon-Signature: t=<unix>,v1=<signature>. The signature is hex HMAC-SHA256, with your secret, of t + . + the body exactly as received. Compute it over the raw body (before JSON.parse), compare in constant time and reject timestamps older than 5 minutes.
import crypto from "node:crypto";
// Checks the Muvon-Signature header against the raw body (the exact bytes received, before JSON.parse).
// secret is your endpoint's whsec_… for that mode (test and live have different secrets).
export function verifyMuvonSignature(rawBody, header, secret, toleranceSec = 300) {
const parts = Object.fromEntries(
String(header ?? "").split(",").map((p) => p.trim().split("=")),
);
const t = Number(parts.t);
if (!t || !parts.v1) return false;
// Old timestamps are rejected so a captured request can't be replayed later
if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(parts.v1, "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}import express from "express";
import { verifyMuvonSignature } from "./verify.js";
const app = express();
// express.raw keeps the body as bytes: the signature is over the raw body, not re-serialized JSON
app.post("/webhooks/muvon", express.raw({ type: "application/json" }), async (req, res) => {
const raw = req.body.toString("utf8");
if (!verifyMuvonSignature(raw, req.get("Muvon-Signature"), process.env.MUVON_WEBHOOK_SECRET)) {
return res.status(400).send("bad signature");
}
const event = JSON.parse(raw);
// The same event can arrive twice: skip ids you already processed
if (await alreadyProcessed(event.id)) return res.sendStatus(200);
switch (event.type) {
case "booking.paid":
await markOrderPaid(event.data.object.external_ref);
break;
case "booking.cancelled":
await markOrderCancelled(event.data.object.external_ref, event.data.object.cancel_reason);
break;
}
await rememberProcessed(event.id);
res.sendStatus(200); // answer within 10 s; do slow work after answering
});Responses and retries
Answer with any 2xx within 10 seconds. Anything else, a network error or more than 10 s counts as a failure, and we retry after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h and 24 h (8 attempts in all). After that the delivery is marked failed; you see it in the portal and can recover the event with GET /events.
- An event can arrive more than once: ignore
ids you already processed. - They can arrive out of order. If you need the current state, call
GET /bookings/{ref}. - Answer first and process afterwards, to stay under 10 s.

