Mews adapter
Mapping Mews API → types normalisés Bell, endpoints utilisés, canal tracking via CommanderOrigin
Mews est l'adapter de référence. 8 capabilities implémentées, éprouvé contre la sandbox Mews. Point de départ pour comprendre comment intégrer un autre PMS.
Identité Mews
- Éditeur : Mews Systems (Amsterdam, NL)
- API : Mews Connector API
- Auth : 2 tokens en body de chaque requête (
ClientToken+AccessToken) - Format : JSON-only, POST partout (Mews n'utilise pas REST classique)
- Version : on cible
2023-06-06(stable)
Capabilities supportées
Les 8 capabilities sont toutes implémentées :
| Capability | Mews endpoint | Méthode adapter |
|---|---|---|
rooms | POST /resources/getAll/2023-06-06 | getRooms() |
guests | POST /customers/getAll/2023-06-06 | getGuests(ids) + getGuestById(id) |
reservations | POST /reservations/getAll/2023-06-06 | getReservations(opts) + getReservationById(id) |
webhooks | Integration Webhooks (général) | parseWebhookEvent() + verifyWebhookSignature() |
checkin | POST /reservations/start/2023-06-06 | startReservation(id) |
checkout | POST /reservations/process/2023-06-06 | processReservation(id) |
charges | POST /orders/add/2023-06-06 | addOrder(params) |
room-status | POST /resources/update/2023-06-06 | updateRoomStatus(params) |
Mapping des états
Réservation (Mews State → normalisé)
| Mews State | Normalisé |
|---|---|
Confirmed | confirmed |
Started | checked_in |
Processed | checked_out |
Optional | canceled (ou inquiry selon contexte) |
Canceled | canceled |
Room state (Mews State → normalisé)
| Mews State | Normalisé |
|---|---|
Dirty | cleaning |
Clean | available |
Inspected | available |
OutOfService | maintenance |
OutOfOrder | maintenance |
Channel inference (feature différenciante)
Le CommanderOrigin est un champ Mews qui indique d'où vient la réservation. C'est ce qui nous permet de détecter Booking/Expedia/Airbnb sans intégrer ces OTAs nous-mêmes.
export function inferChannel(mewsOrigin: string | undefined): BookingChannel {
if (!mewsOrigin) return "direct";
const o = mewsOrigin.toLowerCase();
if (o.includes("booking")) return "booking";
if (o.includes("expedia")) return "expedia";
if (o.includes("airbnb")) return "airbnb";
if (o.includes("hrs")) return "hrs";
if (o.includes("hotelbeds")) return "hotelbeds";
if (o.includes("agoda")) return "agoda";
if (o.includes("mews")) return "mews";
if (o.includes("direct") || o === "") return "direct";
return "other";
}Stocké dans guest.externalChannel au moment du sync. Utilisé par le dashboard staff pour les badges colorés et les analytics.
Structure de l'adapter
import type { IntegrationAdapter, NormalizedGuest, NormalizedRoom, NormalizedReservation } from "../types";
interface MewsCredentials {
clientToken: string;
accessToken: string;
client: string; // "Bell by HOAIY Demo 1.0"
}
export class MewsAdapter implements IntegrationAdapter {
readonly provider = "mews";
readonly capabilities = new Set([
"rooms", "guests", "reservations", "webhooks",
"checkin", "checkout", "charges", "room-status",
] as const);
constructor(
private credentials: MewsCredentials,
private platformUrl: string, // "https://api.mews-demo.com"
) {}
private async post<T>(path: string, body: Record<string, unknown>): Promise<T> {
const res = await fetch(`${this.platformUrl}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...this.credentials,
...body,
}),
});
if (!res.ok) {
const text = await res.text();
throw new MewsApiError(res.status, text);
}
return res.json();
}
async testConnection() {
const data = await this.post<{ Enterprise: { Name: string; Id: string } }>(
"/api/connector/v1/configuration/get",
{},
);
return {
success: true,
propertyName: data.Enterprise.Name,
propertyId: data.Enterprise.Id,
};
}
async getRooms(): Promise<NormalizedRoom[]> {
const data = await this.post<{ Resources: MewsResource[] }>(
"/api/connector/v1/resources/getAll/2023-06-06",
{ Limitation: { Count: 1000 } },
);
return data.Resources.map((r) => ({
externalId: r.Id,
number: r.Name,
floor: r.Data?.FloorName,
type: r.Data?.CategoryName,
status: mapMewsRoomState(r.State),
}));
}
async getGuests(ids?: string[]): Promise<NormalizedGuest[]> {
const data = await this.post<{ Customers: MewsCustomer[] }>(
"/api/connector/v1/customers/getAll/2023-06-06",
ids ? { CustomerIds: ids } : { Limitation: { Count: 1000 } },
);
return data.Customers.map((c) => ({
externalId: c.Id,
firstName: c.FirstName ?? "Guest",
lastName: c.LastName ?? "",
email: c.Email ?? undefined,
phone: c.Phone ?? undefined,
isVip: c.Classifications?.some((cl) => cl.toLowerCase().includes("vip")) ?? false,
}));
}
async getReservations(opts: { states?: ReservationState[] }): Promise<NormalizedReservation[]> {
const now = new Date();
const in60days = new Date(now.getTime() + 60 * 24 * 60 * 60 * 1000);
const data = await this.post<{ Reservations: MewsReservation[] }>(
"/api/connector/v1/reservations/getAll/2023-06-06",
{
ScheduledStartUtc: {
StartUtc: now.toISOString(),
EndUtc: in60days.toISOString(),
},
States: opts.states?.map(mapNormalizedToMewsState),
Limitation: { Count: 1000 },
Extent: { Reservations: true, Customers: true },
},
);
return data.Reservations.map((r) => ({
externalId: r.Id,
guestExternalId: r.CustomerId,
roomExternalId: r.AssignedResourceId ?? undefined,
state: mapMewsResState(r.State),
checkIn: r.StartUtc,
checkOut: r.EndUtc,
source: "mews",
channel: inferChannel(r.CommanderOrigin),
confirmationNumber: r.Number,
}));
}
parseWebhookEvent(body: unknown, headers?: Record<string, string>) {
const mwEvent = body as MewsWebhookEvent;
return mewsEventsToNormalized(mwEvent);
}
verifyWebhookSignature(rawBody: string, signature: string, secret: string): boolean {
const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
return timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}
async startReservation(reservationId: string) {
await this.post("/api/connector/v1/reservations/start/2023-06-06", {
ReservationIds: [reservationId],
});
}
async processReservation(reservationId: string) {
await this.post("/api/connector/v1/reservations/process/2023-06-06", {
ReservationIds: [reservationId],
});
}
async addOrder(params: { guestId: string; items: NormalizedOrderItem[]; reservationId?: string }) {
const data = await this.post<{ OrderId: string }>(
"/api/connector/v1/orders/add/2023-06-06",
{
AccountId: params.guestId,
BillId: params.reservationId,
Items: params.items.map((i) => ({
Name: i.name,
UnitCount: i.unitCount,
UnitAmount: { Currency: i.currency, NetValue: i.unitAmount },
Notes: i.notes,
})),
},
);
return { orderId: data.OrderId };
}
async updateRoomStatus(params: { roomId: string; status: RoomStatus }) {
await this.post("/api/connector/v1/resources/update/2023-06-06", {
ResourceUpdates: [{
ResourceId: params.roomId,
State: mapNormalizedToMewsRoomState(params.status),
}],
});
}
}Webhooks Mews supportés
Events qu'on consomme :
| Mews Event | Action Bell |
|---|---|
ReservationCreated | Upsert reservation + guest |
ReservationUpdated | Update reservation |
ReservationStateChanged | Update state (ex: Confirmed → Started) |
CustomerUpdated | Update guest |
ResourceUpdated | Update room status |
ServiceOrderUpdated | Sync order (post-charges confirmation) |
Limitations connues
getReservationsprend un window de 3 mois max côté Mews. On limite à 60 jours pour pagination.- Le champ
Statepeut êtreOptionaldans des cas bizarres (brouillon) — on mappe verscanceledpar défaut. CustomersGetAlllimite à 1000 customers par appel → on batch dansIntegrationSyncService.- Certains endpoints (
orders/add) nécessitentAccountId(le guest), pasReservationId. Erreur fréquente au début.
Tests
Contract test + happy path. Mocks via msw pour ne pas hit la vraie sandbox en CI :
import { runAdapterContractTests } from "../testing/contract";
import { MewsAdapter } from "./mews";
import { mewsFixtures } from "./mews.fixtures";
runAdapterContractTests({
name: "MewsAdapter",
build: () => new MewsAdapter(mewsFixtures.credentials, "https://api.mews-demo.com"),
expectedCapabilities: ["rooms", "guests", "reservations", "webhooks", "checkin", "checkout", "charges", "room-status"],
mocks: {
"/api/connector/v1/configuration/get": mewsFixtures.configurationGet,
"/api/connector/v1/resources/getAll/2023-06-06": mewsFixtures.resourcesGetAll,
// ...
},
});
test("inferChannel maps Booking.com CommanderOrigin to 'booking'", () => {
expect(inferChannel("Booking.com")).toBe("booking");
expect(inferChannel("booking.com")).toBe("booking");
expect(inferChannel("Expedia Partner Central")).toBe("expedia");
expect(inferChannel(undefined)).toBe("direct");
expect(inferChannel("Random OTA")).toBe("other");
});Tests d'intégration (bout en bout) : un script scripts/test-mews-sandbox.ts fait un vrai roundtrip contre la sandbox Mews avec les credentials dev, utilisé uniquement en local pour valider après une refacto.
Credentials sandbox (dev)
Fournis par Mews au onboarding. Stockés chiffrés dans integration.credentials (JSONB + chiffrement AES avec ENCRYPTION_KEY). Jamais dans un .env.
Format :
{
"clientToken": "...",
"accessToken": "...",
"client": "Bell by HOAIY Demo 1.0"
}Pour setup en dev local, admin HOAIY utilise /admin/system/integrations → "Add Mews" → colle les credentials → "Test connection" → "Save".
Roadmap
- Opera : c'est le prochain adapter (gros marché hôtellerie haut-de-gamme). Oracle PMS, API OPERA Cloud.
- Cloudbeds : PMS midmarket, API REST classique.
- Protel : gros en Europe, API SOAP (attention).
- Apaleo : challenger Mews, API propre.
Chaque ajout = 1 semaine environ via le pattern + skill bell-pms-adapter.
Lien
- PMS adapter pattern
- Database schema integrations
- Onboarding hôtel — comment configurer Mews pour un client