Skip to content
Last updated

MOGU Webhooks

Webhooks let you receive near real-time HTTP notifications when events happen in MOGU (trips created or updated, bookings confirmed, payments processed...). Instead of polling the API, your server receives a POST request the moment something changes.

Webhooks are available on Premium and Enterprise plans.

Setup

1. Create a webhook endpoint

Go to Settings → Integrations → Webhooks and click Create endpoint.

You'll need to provide:

  • Name — a label for your own reference (e.g. "My System")
  • URL — the HTTPS endpoint that will receive events (HTTP is not accepted)
  • Events — the event types you want to subscribe to

On creation, MOGU generates a signing secret (whsec_...). Copy it immediately — it is shown only once and cannot be retrieved again.

2. Configure your server

Your endpoint must:

  • Accept POST requests
  • Return HTTP 2xx within 30 seconds
  • Be reachable via a public HTTPS URL (see Local development for testing)

If your server returns a non-2xx response or times out, the delivery is recorded as failed. There is no automatic retry in the current version. A delivery log and application-level retry with exponential backoff are planned for a future release.


Event catalog

EventDescription
trip.createdA new trip is created
trip.updatedTrip metadata changes (title, dates, countries...)
trip.configUpdatedBuilder content saved. Debounced (60s)
booking.createdA new booking is created
booking.updatedBooking lifecycle or payment status changes
payment.succeededA payment is processed successfully
payment.failedA payment attempt fails
payment.refundedA refund is completed

trip.updated vs trip.configUpdated: trip.updated fires on lightweight metadata edits (eg: title, dates...). trip.configUpdated fires when the full trip content for the public proposal is saved from the Builder — it is debounced to avoid flooding your endpoint during active editing sessions.

Detailed trigger map

The table below lists every MOGU action and the webhook event it fires. Use this to understand exactly when your endpoint will receive a notification.

Trip events

ActionEventNotes
Create triptrip.created
Duplicate triptrip.created
AI importtrip.created
Edit metadatatrip.updated
Save in Buildertrip.configUpdatedDebounced 60s
Change GIAV configtrip.configUpdatedPart of Builder save

Booking events

ActionEvent
Checkout completed (or manual)booking.created
Traveler joins via join codebooking.created
Agent cancels a bookingbooking.updated
Payment status changesbooking.updated
Booking activated after paymentbooking.updated
Agent adds/updates/removes a travelerbooking.updated

Payment events

ActionEvent
Payment gateway charge succeedspayment.succeeded
Scheduled installment succeedspayment.succeeded
Agent marks payment as paidpayment.succeeded
Payment gateway charge failspayment.failed
Scheduled installment failspayment.failed
Payment gateway refund completespayment.refunded
Agent marks payment as refundedpayment.refunded

Payload reference

Every webhook request is an HTTP POST with the following headers:

HeaderValue
Content-Typeapplication/json
X-Mogu-EventEvent type (e.g. booking.created) — useful for routing without parsing the body
X-Mogu-DeliveryUnique delivery ID — same value as eventId in the payload
X-Mogu-Signature-256sha256=<hex_digest> — HMAC-SHA256 signature of the raw request body

The body is a JSON object with the following envelope:

{
  "eventId": "evt_550e8400-e29b-41d4-a716-446655440000",
  "type": "trip.created",
  "createdAt": "2026-04-06T12:00:00Z",
  "accountId": 123,
  "data": {
    // resource object — see below
  }
}
FieldTypeDescription
eventIdstringUnique event ID (evt_ + UUID v4). Use for idempotency
typestringEvent type, e.g. trip.created
createdAtstringISO 8601 timestamp
accountIdnumberMOGU account that owns the resource
dataobjectFull resource at time of delivery (see shapes below)

Trip events

Events: trip.created, trip.updated, trip.configUpdated

data contains a trip object with the same shape as GET /trips/{tripId} from the public API.

{
  "eventId": "evt_...",
  "type": "trip.updated",
  "createdAt": "2026-04-06T12:00:00Z",
  "accountId": 123,
  "data": {
    "trip": {
      "id": 42,
      "title": "Costa Rica 8 días",
      "code": "CR-001",
      "duration": 8,
      "updatedAt": "2026-04-06T11:59:45Z"
      // ... full trip object
    }
  }
}

Booking events

Events: booking.created, booking.updated

data contains a booking object with the booking record at the time of delivery.

{
  "eventId": "evt_...",
  "type": "booking.created",
  "createdAt": "2026-04-06T12:00:00Z",
  "accountId": 123,
  "data": {
    "booking": {
      "id": 99,
      "code": "CR001-001",
      "tripId": 42,
      "seats": 2,
      "lifecycleStatus": "active",
      "paymentStatus": "fully_paid",
      "buyerName": "Ana",
      "buyerSurname": "García",
      "buyerEmail": "ana@example.com",
      "buyerPhoneNumber": "+34612345678",
      "buyerCountry": "ES",
      "buyerPostalCode": "28001",
      "buyerTaxid": null,
      "buyerCompanyName": null,
      "buyerLegalAddress": null,
      "checkout": {
        "seats": 2,
        "pricingMode": "fixed",
        "products": {
          "priceSeatAmount": 60000,
          "priceTitle": "Habitación doble"
        },
        "paymentMethod": "card",
        "paymentType": "full",
        "total": 120000,
        "currency": "EUR"
      },
      "notes": [],
      "giavRecordId": 12345,
      "giavRecordCode": "EXP-2026-001",
      "giavCustomerId": 4345520,
      "giavOfficeCode": "54",
      "cancelledAt": null,
      "cancellationReason": null,
      "createdAt": "2026-04-06T12:00:00Z",
      "updatedAt": "2026-04-06T12:00:00Z"
    }
  }
}
FieldTypeDescription
idnumberBooking ID
codestringHuman-readable booking code
tripIdnumberID of the trip this booking belongs to
seatsnumberNumber of seats in the booking
lifecycleStatusstringpending, active, or cancelled
paymentStatusstringno_payment_required, outstanding, up_to_date, past_due, fully_paid, fully_refunded, or cancelled
buyerNamestringBuyer's first name
buyerSurnamestringBuyer's last name
buyerEmailstringBuyer's email address
buyerPhoneNumberstring?Buyer's phone number
buyerCountrystring?ISO 3166-1 alpha-2 country code
buyerPostalCodestring?Buyer's postal code
buyerTaxidstring?Buyer's tax ID
buyerCompanyNamestring?Buyer's company name
buyerLegalAddressstring?Buyer's legal address
checkoutobject?Checkout data: products selected, pricing mode, totals
checkout.seatsnumberSeats selected at checkout
checkout.pricingModestringfixed or multi_category
checkout.productsobjectProduct details (priceSeatAmount, priceTitle, categorySelections)
checkout.products.priceSeatAmountnumber?Price per seat in cents (e.g. 60000 = 600.00 EUR)
checkout.paymentMethodstring?Payment method chosen at checkout
checkout.paymentTypestring?full or installment
checkout.totalnumber?Total amount in cents (e.g. 120000 = 1200.00 EUR)
checkout.currencystringISO 4217 currency code
notesarrayAgent notes on the booking
notes[].idstringNote UUID
notes[].contentstringNote text
notes[].agentIdnumberAuthor agent ID
notes[].privatebooleanWhether the note is private
giavRecordIdnumber?GIAV record (expediente) ID
giavRecordCodestring?GIAV record code
giavCustomerIdnumber?GIAV customer ID
giavOfficeCodestring?GIAV office code
giavSalesBookingIdnumber?GIAV sales booking ID
giavCostBookingIdnumber?GIAV cost booking ID
cancelledAtstring?ISO 8601 timestamp of cancellation
cancellationReasonstring?Reason for cancellation
createdAtstringISO 8601 timestamp
updatedAtstringISO 8601 timestamp

Payment events

Events: payment.succeeded, payment.failed, payment.refunded

data contains a payment object representing the payment link that triggered the event.

{
  "eventId": "evt_...",
  "type": "payment.succeeded",
  "createdAt": "2026-04-06T12:00:00Z",
  "accountId": 123,
  "data": {
    "payment": {
      "id": 7,
      "code": "pay_a1b2c3",
      "bookingId": 99,
      "concept": "Costa Rica 8 días — 2x Habitación doble",
      "amount": 120000,
      "currency": "EUR",
      "status": "paid",
      "paymentMethod": "card",
      "receiptUrl": "https://pay.stripe.com/receipts/...",
      "paidAt": "2026-04-06T12:00:00Z",
      "refundedAt": null,
      "expiresAt": "2026-05-06T12:00:00Z",
      "scheduledAt": null,
      "createdAt": "2026-04-01T10:00:00Z",
      "updatedAt": "2026-04-06T12:00:00Z"
    }
  }
}
FieldTypeDescription
idnumberPayment link ID
codestringUnique payment link code
bookingIdnumber?ID of the associated booking
conceptstringPayment description
amountnumberAmount in cents (e.g. 120000 = 1200.00 EUR)
currencystringISO 4217 currency code (e.g. EUR)
statusstringpending, paid, failed, refunded, scheduled, past_due, expired, or cancelled
paymentMethodstring?card, transfer, us_bank_account, or manual
receiptUrlstring?URL to the payment receipt
paidAtstring?ISO 8601 timestamp of payment
refundedAtstring?ISO 8601 timestamp of refund
expiresAtstring?ISO 8601 expiration timestamp
scheduledAtstring?ISO 8601 scheduled charge date
createdAtstringISO 8601 timestamp
updatedAtstringISO 8601 timestamp

Test event

Event: webhook.test

Sent when you click Send test in Settings. The payload contains dummy data and should be used only to verify your endpoint is reachable and your signature verification is working. You will be able to use it even if your endpoint is paused.

{
  "eventId": "evt_...",
  "type": "webhook.test",
  "createdAt": "2026-04-06T12:00:00Z",
  "accountId": 123,
  "data": {
    "test": {}
  }
}

Signature verification

Every request includes an X-Mogu-Signature-256 header so you can verify the request came from MOGU and was not tampered with.

Header format:

X-Mogu-Signature-256: sha256=<hex_digest>

The hex digest is computed as HMAC-SHA256(signing_secret, raw_request_body).

Important: Always verify the signature using the raw request body before parsing JSON. Parsing first can alter whitespace and break verification.

Node.js

const crypto = require('crypto');

function verifySignature(rawBody, signatureHeader, secret) {
  const expected = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex');

  const expectedBuf = Buffer.from(expected);
  const signatures = signatureHeader.split(',').map(s => s.trim());
  return signatures.some(sig => {
    const sigBuf = Buffer.from(sig);
    return sigBuf.length === expectedBuf.length
      && crypto.timingSafeEqual(sigBuf, expectedBuf);
  });
}

Python

import hmac
import hashlib

def verify_signature(raw_body: bytes, signature_header: str, secret: str) -> bool:
    expected = 'sha256=' + hmac.new(
        secret.encode(),
        raw_body,
        hashlib.sha256
    ).hexdigest()

    signatures = [s.strip() for s in signature_header.split(',')]
    return any(
        hmac.compare_digest(sig, expected)
        for sig in signatures
    )

Note: The header may contain two comma-separated signatures during a secret rotation (24h overlap window). The examples above handle this correctly — accept the request if any signature matches.


Express handler example

A complete Node.js/Express handler that verifies the signature and processes events:

const express = require('express');
const crypto = require('crypto');

const app = express();
const WEBHOOK_SECRET = process.env.MOGU_WEBHOOK_SECRET;

// Use raw body parser — signature verification requires the raw bytes
app.post('/webhooks/mogu', express.raw({ type: 'application/json' }), (req, res) => {
  const signatureHeader = req.headers['x-mogu-signature-256'];

  if (!signatureHeader || !verifySignature(req.body, signatureHeader, WEBHOOK_SECRET)) {
    return res.status(401).send('Invalid signature');
  }

  const event = JSON.parse(req.body);

  // Respond 2xx immediately, process async
  res.status(200).send('OK');

  handleEvent(event).catch(err => console.error('Event handling error:', err));
});

async function handleEvent(event) {
  switch (event.type) {
    case 'trip.updated':
    case 'trip.configUpdated':
      console.log('Trip updated:', event.data.trip.id);
      await syncTrip(event.data.trip);
      break;

    case 'booking.created':
      console.log('New booking:', event.data.booking.id);
      await createReservation(event.data.booking);
      break;

    case 'payment.succeeded':
      console.log('Payment received:', event.data.payment.id);
      await confirmPayment(event.data.payment);
      break;

    case 'webhook.test':
      console.log('Test event received — webhook is working.');
      break;

    default:
      console.log('Unhandled event type:', event.type);
  }
}

function verifySignature(rawBody, signatureHeader, secret) {
  const expected = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex');

  const expectedBuf = Buffer.from(expected);
  return signatureHeader.split(',')
    .map(s => s.trim())
    .some(sig => {
      const sigBuf = Buffer.from(sig);
      return sigBuf.length === expectedBuf.length
        && crypto.timingSafeEqual(sigBuf, expectedBuf);
    });
}

Best practices

Respond quickly, process asynchronously

Your endpoint must return HTTP 2xx within 30 seconds. For any processing that could take longer (database writes, downstream API calls), acknowledge the webhook immediately and handle it in a background job.

Use eventId for idempotency

Each event includes a unique eventId. We recommend using it as a deduplication key so your handler is idempotent:

const alreadyProcessed = await db.webhookEvents.findOne({ eventId: event.eventId });
if (alreadyProcessed) return; // skip duplicate

await db.webhookEvents.insert({ eventId: event.eventId });
// ... process event

Handle out-of-order delivery

Webhooks are not guaranteed to arrive in order. A booking.updated may arrive before booking.created. Use updatedAt from the payload to detect stale events:

case 'booking.updated': {
  const existing = await db.bookings.findOne({ id: event.data.booking.id });
  if (existing && new Date(existing.updatedAt) >= new Date(event.data.booking.updatedAt)) {
    console.log('Skipping stale event');
    return;
  }
  await db.bookings.upsert(event.data.booking);
}

Always verify the signature

Never process a webhook payload without first verifying the X-Mogu-Signature-256 header. Failing to do so exposes your server to spoofed events.

Rotate secrets periodically

Use Settings → Webhooks → Rotate secret to generate a new signing secret. MOGU provides a 24-hour overlap window during which both the old and new secrets are valid — this gives you time to deploy the updated secret on your side without dropping events.


Local development

Your endpoint must be accessible over a public HTTPS URL — localhost is not accepted. During development, use a tunneling tool to expose your local server:

ngrok

ngrok http 3000

ngrok will print a public URL like https://abc123.ngrok.io. Use this as your webhook URL in Settings.

Cloudflare Tunnel

cloudflared tunnel --url http://localhost:3000

Cloudflare Tunnel provides a stable HTTPS URL for the duration of the session.

Tip: Register your local tunnel URL as a webhook endpoint and use the Send test button in Settings to fire a webhook.test event and verify your handler is working end-to-end.


Support