# 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](#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

| Event | Description |
|  --- | --- |
| `trip.created` | A new trip is created |
| `trip.updated` | Trip metadata changes (title, dates, countries...) |
| `trip.configUpdated` | Builder content saved. Debounced (60s) |
| `booking.created` | A new booking is created |
| `booking.updated` | Booking lifecycle or payment status changes |
| `payment.succeeded` | A payment is processed successfully |
| `payment.failed` | A payment attempt fails |
| `payment.refunded` | A 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

| Action | Event | Notes |
|  --- | --- | --- |
| Create trip | `trip.created` |  |
| Duplicate trip | `trip.created` |  |
| AI import | `trip.created` |  |
| Edit metadata | `trip.updated` |  |
| Save in Builder | `trip.configUpdated` | Debounced 60s |
| Change GIAV config | `trip.configUpdated` | Part of Builder save |


#### Booking events

| Action | Event |
|  --- | --- |
| Checkout completed (or manual) | `booking.created` |
| Traveler joins via join code | `booking.created` |
| Agent cancels a booking | `booking.updated` |
| Payment status changes | `booking.updated` |
| Booking activated after payment | `booking.updated` |
| Agent adds/updates/removes a traveler | `booking.updated` |


#### Payment events

| Action | Event |
|  --- | --- |
| Payment gateway charge succeeds | `payment.succeeded` |
| Scheduled installment succeeds | `payment.succeeded` |
| Agent marks payment as paid | `payment.succeeded` |
| Payment gateway charge fails | `payment.failed` |
| Scheduled installment fails | `payment.failed` |
| Payment gateway refund completes | `payment.refunded` |
| Agent marks payment as refunded | `payment.refunded` |


## Payload reference

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

| Header | Value |
|  --- | --- |
| `Content-Type` | `application/json` |
| `X-Mogu-Event` | Event type (e.g. `booking.created`) — useful for routing without parsing the body |
| `X-Mogu-Delivery` | Unique delivery ID — same value as `eventId` in the payload |
| `X-Mogu-Signature-256` | `sha256=<hex_digest>` — HMAC-SHA256 signature of the raw request body |


The body is a JSON object with the following envelope:


```json
{
  "eventId": "evt_550e8400-e29b-41d4-a716-446655440000",
  "type": "trip.created",
  "createdAt": "2026-04-06T12:00:00Z",
  "accountId": 123,
  "data": {
    // resource object — see below
  }
}
```

| Field | Type | Description |
|  --- | --- | --- |
| `eventId` | string | Unique event ID (`evt_` + UUID v4). Use for idempotency |
| `type` | string | Event type, e.g. `trip.created` |
| `createdAt` | string | ISO 8601 timestamp |
| `accountId` | number | MOGU account that owns the resource |
| `data` | object | Full 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}](/openapi/trips/gettripbyid) from the public API.


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


```json
{
  "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"
    }
  }
}
```

| Field | Type | Description |
|  --- | --- | --- |
| `id` | number | Booking ID |
| `code` | string | Human-readable booking code |
| `tripId` | number | ID of the trip this booking belongs to |
| `seats` | number | Number of seats in the booking |
| `lifecycleStatus` | string | `pending`, `active`, or `cancelled` |
| `paymentStatus` | string | `no_payment_required`, `outstanding`, `up_to_date`, `past_due`, `fully_paid`, `fully_refunded`, or `cancelled` |
| `buyerName` | string | Buyer's first name |
| `buyerSurname` | string | Buyer's last name |
| `buyerEmail` | string | Buyer's email address |
| `buyerPhoneNumber` | string? | Buyer's phone number |
| `buyerCountry` | string? | ISO 3166-1 alpha-2 country code |
| `buyerPostalCode` | string? | Buyer's postal code |
| `buyerTaxid` | string? | Buyer's tax ID |
| `buyerCompanyName` | string? | Buyer's company name |
| `buyerLegalAddress` | string? | Buyer's legal address |
| `checkout` | object? | Checkout data: products selected, pricing mode, totals |
| `checkout.seats` | number | Seats selected at checkout |
| `checkout.pricingMode` | string | `fixed` or `multi_category` |
| `checkout.products` | object | Product details (priceSeatAmount, priceTitle, categorySelections) |
| `checkout.products.priceSeatAmount` | number? | Price per seat in cents (e.g. `60000` = 600.00 EUR) |
| `checkout.paymentMethod` | string? | Payment method chosen at checkout |
| `checkout.paymentType` | string? | `full` or `installment` |
| `checkout.total` | number? | Total amount in cents (e.g. `120000` = 1200.00 EUR) |
| `checkout.currency` | string | ISO 4217 currency code |
| `notes` | array | Agent notes on the booking |
| `notes[].id` | string | Note UUID |
| `notes[].content` | string | Note text |
| `notes[].agentId` | number | Author agent ID |
| `notes[].private` | boolean | Whether the note is private |
| `giavRecordId` | number? | GIAV record (expediente) ID |
| `giavRecordCode` | string? | GIAV record code |
| `giavCustomerId` | number? | GIAV customer ID |
| `giavOfficeCode` | string? | GIAV office code |
| `giavSalesBookingId` | number? | GIAV sales booking ID |
| `giavCostBookingId` | number? | GIAV cost booking ID |
| `cancelledAt` | string? | ISO 8601 timestamp of cancellation |
| `cancellationReason` | string? | Reason for cancellation |
| `createdAt` | string | ISO 8601 timestamp |
| `updatedAt` | string | ISO 8601 timestamp |


### Payment events

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

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


```json
{
  "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"
    }
  }
}
```

| Field | Type | Description |
|  --- | --- | --- |
| `id` | number | Payment link ID |
| `code` | string | Unique payment link code |
| `bookingId` | number? | ID of the associated booking |
| `concept` | string | Payment description |
| `amount` | number | Amount in cents (e.g. `120000` = 1200.00 EUR) |
| `currency` | string | ISO 4217 currency code (e.g. `EUR`) |
| `status` | string | `pending`, `paid`, `failed`, `refunded`, `scheduled`, `past_due`, `expired`, or `cancelled` |
| `paymentMethod` | string? | `card`, `transfer`, `us_bank_account`, or `manual` |
| `receiptUrl` | string? | URL to the payment receipt |
| `paidAt` | string? | ISO 8601 timestamp of payment |
| `refundedAt` | string? | ISO 8601 timestamp of refund |
| `expiresAt` | string? | ISO 8601 expiration timestamp |
| `scheduledAt` | string? | ISO 8601 scheduled charge date |
| `createdAt` | string | ISO 8601 timestamp |
| `updatedAt` | string | ISO 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.


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


```javascript
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


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


```javascript
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:


```javascript
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:


```javascript
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


```bash
ngrok http 3000
```

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

### Cloudflare Tunnel


```bash
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

- **Email**: [support@moguplatform.com](mailto:support@moguplatform.com)
- **Help Center**: [https://help.moguplatform.com](https://help.moguplatform.com)