Developers
translateEspañolOpen an account

Get started

  • Reseller API
  • Quickstart

Concepts

  • Authentication and modes
  • Errors
  • Idempotency
  • Rate limits
  • Pagination

Reference

  • Endpoints
  • Objects

Guides

  • Booking lifecycle
  • Payment link and hold
  • Cancellations
  • Webhooks
  • Sandbox

More

  • Changelog
data_objectOpenAPI 3.1
menu_bookWebhooksexpand_more

Get started

  • Reseller API
  • Quickstart

Concepts

  • Authentication and modes
  • Errors
  • Idempotency
  • Rate limits
  • Pagination

Reference

  • Endpoints
  • Objects

Guides

  • Booking lifecycle
  • Payment link and hold
  • Cancellations
  • Webhooks
  • Sandbox

More

  • Changelog
data_objectOpenAPI 3.1

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

TypeWhen
booking.createdA booking was created.
booking.paidYour customer paid.
booking.assigned … booking.completedEach ride status. See Booking lifecycle.
booking.no_showThe passenger didn't show up.
booking.cancelledIt was cancelled: by you, by Muvon or for non-payment.
pingTest 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:

json
{
  "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.

verify.js
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.
arrow_backPreviousCancellationsNextarrow_forwardSandbox

On this page

  • Event types
  • Payload
  • Verify the signature
  • Responses and retries