Embed booking widget
Add the booking widget to your own website.
Last updated
There are two ways to put booking on your own website.
- The booking widget is a ready-made form on an
/embed/widgetpage, that you show on your site in an iframe. Nothing to build. - The Embed API is a set of endpoints,
/api/embed/*, that your venue's API key unlocks. You build the booking flow in your own design and the API does the rest: availability, holding a session, pricing, payment and invoices.
The booking widget#
The widget shows your venue's rooms and free sessions, takes the player's contact details and their card payment, all inside the frame. It makes real bookings: the session is held at once, the booking is confirmed when the payment arrives, and the player gets the same confirmation email as a booking on MyEscapeRoom. It is built on the Embed API below, with the key you give it.
Set it up#
Create a key for the widget
On DashboardSetupAPI, create a new key and name it for the widget. Leave every extra switch off: the widget does not need them, and it refuses a key that has one on. See API keys.
Add your site to Allowed origins
On the same page, add the website that will show the widget, for example
https://www.your-venue.com. Only the sites on this list can show the widget in a frame. Any other site gets an empty frame.Paste the iframe
Put the iframe on your page, with your key in
key. Give it a height of 700 pixels or more: the payment step is the tallest, and the widget scrolls inside the frame if it needs more. Keepallow="payment": without it a browser does not offer Apple Pay or Google Pay inside the frame.Your page<iframe src="https://myescaperoom.eu/embed/widget?key=mer_…&brand=%23cc3311&accent=%234fb8c4&bg=%230e0c0a" title="Book an escape room" allow="payment" width="100%" height="780" style="border:0" ></iframe>
The address#
The widget is served from the MyEscapeRoom host at /embed/widget. A colour is a hex colour (remember to encode # as %23); one that is missing or is not a colour falls back to its default.
| Parameter | Meaning | |
|---|---|---|
key | Required | The widget's own API key, starting with mer_. |
room | Optional | A room's slug, as the rooms list of the Embed API gives it, to start on that room. Players can still pick another. |
brand | Optional | The main colour, for buttons and highlights, as a hex colour. Default #cc3311. |
accent | Optional | The colour of the total, as a hex colour. Default #4fb8c4. |
bg | Optional | The background, as a hex colour. Default #0e0c0a. Text turns dark on a light background and light on a dark one by itself. |
What a player goes through#
- Picks a room (when there is more than one), a day of the week and a session. Times are the venue's time, and only free sessions can be picked.
- Chooses the group size and gives a name, an email and, if your venue asks for it, a phone number. Where the room allows both, they choose to pay online or at the venue, and they can enter a discount code or a gift voucher code. The price is shown as the API works it out.
- Pays by card in the widget. The session is held for 15 minutes meanwhile. A room paid at the venue still takes the service fee online. If the bank asks for a 3-D Secure check, the player comes back to the widget, which carries on. Going back from the payment releases the session at once.
- Sees the confirmation, with the booking reference, the room, the time and the amount, once the booking is confirmed.
The widget follows the same rules as everything else: your booking window, closed days, the staffing rule and each room's payment options decide what players can pick.
If something does not work#
| What you see | What it means |
|---|---|
| The frame is empty, or the browser says it refused to connect | Your site is not in the key's Allowed origins. Add it exactly as visitors see it: https, with www if you use it, no path. https://www.your-venue.com and https://your-venue.com are two origins. |
| “Its key is not valid” | The key in the address is wrong or was revoked, or the venue is not live. Copy the key again from the dashboard, or create a new one. |
| “This key has extra permissions” | The key has one of the extra switches on (May cancel after the player deadline, May manage vouchers or May read guest details). The widget refuses such a key and only this app can frame it, because the key is readable in your page. Create a key with every switch off and use that one. |
| No rooms, or no free sessions | The widget shows the venue's active rooms and their free sessions, the same ones as your public page. A room needs a schedule to have sessions. |
| “Confirming your booking” takes a while | The booking confirms when Stripe reports the payment, usually within seconds. The player still gets the confirmation email if they close the page. |
To build the form in your own design instead, use the Embed API, as the rest of this page shows.
Build your own booking flow#
Everything a booking widget needs is available through the API: your rooms, free sessions, pricing, payment, gift vouchers and invoices.
Before you start#
- Your venue is live. To take online bookings that owe the venue money, it also needs Stripe connected.
- You have a key, see API keys. Keep it on your server.
- Your payment page can load
https://js.stripe.com/v3/. The player pays inside your page with Stripe's own elements, so card details never touch your server.
The shape of the flow#
Your server calls MyEscapeRoom with the key and passes the results to your page. Your page shows rooms and times, collects the contact details, and runs Stripe.js with the clientSecret your server received. This keeps the key out of the browser. You can call the API straight from a page instead, if the page's origin is in your Allowed origins (a browser request from any other origin is refused with 403 ORIGIN_NOT_ALLOWED), but the key is then public: read API keys first.
The examples use a small helper on your server. Every mer(…) call below is this:
// server.js: a tiny helper your server uses for every call.
const BASE = "https://myescaperoom.eu";
const YOUR_API_KEY = "mer_…"; // keep it on your server; read it from your own secret store
export async function mer(path, init = {}) {
const res = await fetch(BASE + path, {
...init,
headers: {
Authorization: "Bearer " + YOUR_API_KEY, // mer_…
"Content-Type": "application/json",
...init.headers,
},
});
const body = await res.json();
if (!res.ok) throw Object.assign(new Error(body.code), { status: res.status, body });
return body;
}A worked example#
List your rooms
The answer is your venue and its active rooms. Show them from here, rather than maintaining a copy.
curl https://myescaperoom.eu/api/embed/rooms \ -H "Authorization: Bearer $MER_API_KEY"Response 200{ "venue": { "id": "5b1e0c7a-8d3f-4e21-9a64-1f0b7c2d9e35", "name": "The Vault Ghent", "address": "Korenmarkt 1", "city": "Ghent", "country": "BE", "timezone": "Europe/Brussels", "contactEmail": "hello@vault.example", "contactPhone": "+32 9 000 00 00", "description": "Five rooms in a former bank.", "images": [], "bookerPhone": "optional", "playerCancellationNoticeHours": 24, "lat": 51.0543, "lng": 3.7174 }, "rooms": [ { "id": "0b8e5c1a-3d47-4f6a-9a21-7c5e1d2b4f90", "venueId": "5b1e0c7a-8d3f-4e21-9a64-1f0b7c2d9e35", "slug": "the-vault", "name": "The Vault", "genre": "Heist", "difficulty": 3, "minPlayers": 2, "maxPlayers": 6, "durationMin": 60, "pricing": { "mode": "per-person", "pricePerPersonCents": 2500 }, "rating": 4.7, "reviewCount": 38, "coverImage": "https://…/the-vault.jpg", "images": ["https://…/the-vault.jpg"], "description": "Break into the bank before the guards return.", "tags": ["team", "puzzles"], "certifiedGreat": false, "allowedPaymentModes": ["online", "at_location"] } ] }allowedPaymentModessays whichpaymentModes a booking of this room accepts.pricingsays how it is priced, see Pricing modes below.venue.timezoneis the zone to show times in. To read one room, useGET /api/embed/rooms/{slug}.Read the free sessions
Ask for one day with
date=2026-11-02, or a window withfrom(included) andto(excluded). Days are in the venue's time zone. The example is the seven days 2 to 8 November.curl "https://myescaperoom.eu/api/embed/rooms/the-vault/availability?from=2026-11-02&to=2026-11-09" \ -H "Authorization: Bearer $MER_API_KEY"Response 200{ "slots": [ { "id": "7d2a91c4-5e60-4b8f-a3d1-92c0e4f61b08", "roomId": "0b8e5c1a-3d47-4f6a-9a21-7c5e1d2b4f90", "start": "2026-11-02T17:00:00.000Z", "end": "2026-11-02T18:00:00.000Z", "status": "available" } ], "total": 14, "hasMore": false, "hasLater": true }Only sessions that can be booked appear: never a started, held or booked one. The group size does not filter anything, because a group takes the whole room. With
to,hasLatertells you whether any session lies beyond the window, so you know when "next week" would be empty. Page withoffsetuntilhasMoreis false.Price it (optional)
Show the player the total before they commit. The quote endpoint is public and changes nothing: it does not hold the session or spend a code.
curl -X POST https://myescaperoom.eu/api/bookings/quote \ -H "Content-Type: application/json" \ -d '{"slotId":"7d2a91c4-5e60-4b8f-a3d1-92c0e4f61b08","teamSize":4,"paymentMode":"online"}'Response 200{ "quote": { "roomSubtotalCents": 10000, "discountCents": 0, "voucherCents": 0, "platformFeeCents": 500, "totalCents": 10500, "onlineChargeCents": 10500, "dueAtVenueCents": 0 }, "allowedPaymentModes": ["online", "at_location"] }The service fee in these examples is 5.00 euros. It is a platform setting and is always read from the answer, never hard-coded. You can also pass
discountCodeorvoucherCodeto check them. A voucher code that is real but empty is reported asVOUCHER_INVALIDhere, on purpose.Create the booking
Send the session, the contact and the group. The session is held for the player at once.
curl -X POST https://myescaperoom.eu/api/embed/bookings \ -H "Authorization: Bearer $MER_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "slotId": "7d2a91c4-5e60-4b8f-a3d1-92c0e4f61b08", "guestName": "Sofie V.", "guestEmail": "sofie@example.com", "phone": "+32 470 00 00 00", "teamSize": 4, "paymentMode": "online" }'Response 201{ "bookingId": "c41e8a07-2b9d-4d63-8f15-0a7e3b6c9d21", "ref": "BK-ABCD1234", "clientSecret": "pi_3Qx…_secret_…", "stripePublishableKey": "pk_live_…", "quote": { "roomSubtotalCents": 10000, "discountCents": 0, "voucherCents": 0, "platformFeeCents": 500, "totalCents": 10500, "onlineChargeCents": 10500, "dueAtVenueCents": 0 }, "booking": { "id": "c41e8a07-2b9d-4d63-8f15-0a7e3b6c9d21", "ref": "BK-ABCD1234", "slotId": "7d2a91c4-5e60-4b8f-a3d1-92c0e4f61b08", "roomId": "0b8e5c1a-3d47-4f6a-9a21-7c5e1d2b4f90", "venueId": "5b1e0c7a-8d3f-4e21-9a64-1f0b7c2d9e35", "guestName": null, "guestEmail": null, "phone": null, "teamSize": 4, "roomSubtotalCents": 10000, "discountCents": 0, "voucherCents": 0, "platformFeeCents": 500, "onlineChargeCents": 10500, "dueAtVenueCents": 0, "totalCents": 10500, "currency": "eur", "status": "pending", "paymentMode": "online", "paymentLast4": null, "feePaidAt": null, "paidAt": null, "refundedCents": null, "cancelledAt": null, "cancelledBy": null, "cancellationReason": null, "refundStatus": null, "createdAt": "2026-10-19T09:41:07.120Z" }, "room": { "slug": "the-vault", "name": "The Vault" }, "slot": { "start": "2026-11-02T17:00:00.000Z", "end": "2026-11-02T18:00:00.000Z" } }Keep the
ref(BK-ABCD1234): it is how you look the booking up and how the player refers to it. Showquote.totalCentsas the amount. Thebooking,roomandslotare whatGET /api/embed/bookings/{ref}returns, so you do not need a second call for the session's time. A booking always counts as a guest booking: it is not attached to a MyEscapeRoom player account. The guest's name, email and phone arenullin the answer, as in the example, unless the key has May read guest details: you just sent them, so keep them on your side.Take the payment in the browser
Pass the
clientSecretandstripePublishableKeyto your payment page and confirm with Stripe.js.Your payment page<!-- Your payment page, in the player's browser. --> <script src="https://js.stripe.com/v3/"></script> <div id="payment-element"></div> <button id="pay">Pay</button> <script> // clientSecret and stripePublishableKey come from your server's POST /api/embed/bookings. const stripe = Stripe(stripePublishableKey); const elements = stripe.elements({ clientSecret }); elements.create("payment").mount("#payment-element"); document.getElementById("pay").addEventListener("click", async () => { const { error } = await stripe.confirmPayment({ elements, // Where a bank's 3-D Secure page sends the player back to. confirmParams: { return_url: "https://www.your-venue.com/booking/done?ref=BK-ABCD1234" }, redirect: "if_required", }); if (error) { // Show error.message and let the player try again with the same elements. return; } // Paid, or being processed. Now ask your server whether the booking is confirmed. }); </script>The payment is created on the MyEscapeRoom platform account, so use the
stripePublishableKeyfrom the answer, and no connected-account option. Venues and keys never see card data.Wait until it is confirmed
The browser coming back from Stripe does not confirm the booking. It becomes
confirmedwhen Stripe's payment notification reaches MyEscapeRoom. Add a webhook endpoint with thebooking.confirmedevent and MyEscapeRoom tells your server the moment it happens, with the booking and the contact details in the body. Your payment page can then ask your own server whether it has heard. If you would rather not run an endpoint, read the booking from your server instead.curl https://myescaperoom.eu/api/embed/bookings/BK-ABCD1234 \ -H "Authorization: Bearer $MER_API_KEY"Response 200{ "booking": { "ref": "BK-ABCD1234", "status": "confirmed", "paymentMode": "online", "paymentLast4": "4242", "feePaidAt": "2026-10-19T09:42:31.000Z", "paidAt": "2026-10-19T09:42:31.000Z", "totalCents": 10500 // …the other booking fields, as above }, "room": { "slug": "the-vault", "name": "The Vault" }, "slot": { "start": "2026-11-02T17:00:00.000Z", "end": "2026-11-02T18:00:00.000Z" } }booking.statusgoes frompendingtoconfirmed, or tocancelledif the hold ran out unpaid. MyEscapeRoom emails the player their confirmation when it confirms. Do not tell the player a payment failed until the hold has ended: Stripe's delivery time is not ours to promise, and it retries a failed delivery.Cancel or release (when needed)
# Release an unpaid hold, or cancel a confirmed booking inside the player notice. curl -X POST https://myescaperoom.eu/api/embed/bookings/BK-ABCD1234/cancel \ -H "Authorization: Bearer $MER_API_KEY" \ -H "Content-Type: application/json" \ -d '{"reason":"The group asked to move to another day"}' # After the player deadline, as the venue (needs "May cancel after the player deadline"): curl -X POST https://myescaperoom.eu/api/embed/bookings/BK-ABCD1234/cancel \ -H "Authorization: Bearer $MER_API_KEY" \ -H "Content-Type: application/json" \ -d '{"asVenue":true,"reason":"Room out of order"}'An unpaid booking is released: the session is free again at once and the payment is cancelled. Do this when a player leaves checkout. A confirmed booking is cancelled, with a refund of the room money paid online, never the service fee. The answer's
booking.refundStatusispending(the refund follows within minutes) ornot_needed, andbooking.refundedCentsis the amount decided. A cancelled booking sends abooking.cancelledwebhook, whoever cancelled it; a released one does not.By default a cancellation is held to the same notice as a player's own: the more generous of the notice in force when the booking was made and the venue's current one. You get
403 CANCELLATION_NOT_ALLOWEDif neither lets players cancel, and409 CANCELLATION_DEADLINE_PASSEDonce the deadline is behind. Only a key with May cancel after the player deadline can sendasVenue: true, which cancels until the game starts.
Request fields#
The body of POST /api/embed/bookings:
| Field | Meaning | |
|---|---|---|
slotId | Required | The session's id, from the availability answer. |
guestName | Required | The contact the venue asks for, up to 200 characters. Not a participant. |
guestEmail | Required | Where the confirmation and the receipt go. |
teamSize | Required | How many people are coming. Whole number, within the room's minPlayers and maxPlayers. |
paymentMode | Required | online or at_location. One of the room's allowedPaymentModes. |
phone | Optional | Up to 40 characters. Whether it is needed depends on the venue's setting: see below. |
discountCode | Optional | A discount code of the venue. Up to 64 characters. |
voucherCode | Optional | A gift voucher code of the venue. Up to 64 characters. |
invoice | Optional | The "Need an invoice?" object. Belgian venues only. |
The booking stores no game language, notes or email language, so keep those on your side. An amount in the body is ignored: the price is always worked out on the server from the room's own pricing.
Phone. The venue's bookerPhone setting (in venue on the rooms answer) decides: required refuses a booking without a plausible number, optional keeps what you send, and hidden drops any number sent. Mirror it in your form.
Payment modes#
| paymentMode | What is charged |
|---|---|
online | The player pays the whole total now: room price less any discount or voucher, plus the service fee. |
at_location | The player pays the service fee now, and the rest at the venue (dueAtVenueCents). Only when the room's allowedPaymentModes includes it. |
Pricing modes#
| pricing.mode | The room price |
|---|---|
per-person | pricePerPersonCents multiplied by teamSize. |
per-game | One price, priceCents, whatever the group size. |
tiered | The price for exactly that group size, from the tiers map. A size with no tier cannot be priced. |
contact | Not bookable through the API: a booking is refused with 422 CONTACT_PRICING. |
The price breakdown#
totalCents = roomSubtotalCents − discountCents − voucherCents + platformFeeCents, and the total equals onlineChargeCents + dueAtVenueCents. The service fee is always paid online and is never discounted. The venue receives 100% of the room price, less Stripe's card fee on that share.
Payment handling#
- The hold. A new booking holds its session for 15 minutes. A sweep runs every minute, so an unpaid session is back on sale about a minute after the hold ends.
- Always a payment. Even with
at_location, or with a voucher that covers the whole room, the service fee is paid online, so every booking returns aclientSecret. - Failed card attempts. Let the player try again with the same payment element. The hold keeps the session meanwhile.
- Leaving checkout. Call the cancel endpoint to free the session at once. Do not rely on the page unloading.
- Confirmation. Subscribe to
booking.confirmedandbooking.cancelledin your webhooks, or readGET /api/embed/bookings/{ref}from your server.booking.cancelledis sent for a confirmed booking only, not for an unpaid hold that is released or runs out. - Stripe not answering.
502 STRIPE_ERRORmeans the session was released, so try again.
Gift vouchers#
The same key sells gift vouchers from your own shop. You choose the amount (100 to 100,000 cents) and MyEscapeRoom mints the code. A voucher is valid for 180 days, for the whole venue or for one room (roomSlug).
Create the sale
curl -X POST https://myescaperoom.eu/api/embed/vouchers \ -H "Authorization: Bearer $MER_API_KEY" \ -H "Content-Type: application/json" \ -d '{"amountCents":10000,"buyerName":"Sofie V.","buyerEmail":"sofie@example.com"}'Response 201{ "voucher": { "code": "GIFT-7K3M-Q9XD-2VBN", "status": "pending_payment", "amountCents": 10000, "remainingCents": 10000, "expiresAt": "2027-04-17T09:50:12.000Z", "roomSlug": null, "buyerName": "Sofie V.", "buyerEmail": "sofie@example.com", "paidAt": null, "soldOnline": false, "createdAt": "2026-10-19T09:50:12.000Z" }, "clientSecret": "pi_3Qy…_secret_…", "stripePublishableKey": "pk_live_…" }Confirm the payment
ConfirmclientSecretwith Stripe.js in the buyer's browser, exactly as for a booking. No service fee is charged on a voucher sale.Wait until it is active
PollGET /api/embed/vouchers/{code}untilvoucher.statusisactive, then show the buyer the code. MyEscapeRoom emails the voucher tobuyerEmailonce it is active, unless you sentnotifyBuyer: false. Or let a webhook (voucher.sold) tell you.
To spend a voucher, add its code to a booking. The service fee is still paid online.
// Spend a voucher on a booking: add its code to the booking request.
await mer("/api/embed/bookings", {
method: "POST",
body: JSON.stringify({
slotId: "7d2a91c4-5e60-4b8f-a3d1-92c0e4f61b08",
guestName: "Sofie V.",
guestEmail: "sofie@example.com",
teamSize: 4,
paymentMode: "online",
voucherCode: "GIFT-7K3M-Q9XD-2VBN", // and/or discountCode: "ESCAPE10"
}),
});
// quote.voucherCents is what the voucher covered. The service fee is still paid online.Selling a voucher you were paid for in your own shop (collectPayment: false), listing vouchers and cancelling one you sold yourself need a key with May manage vouchers: use it from your server only. See API reference: Vouchers for every endpoint, including cancelling and the voucher's invoice. The buyer's buyerName and buyerEmail come back in voucher answers only to a key with May manage vouchers or May read guest details; for any other key they are null.
Invoices#
At a Belgian venue a booker can ask for invoices. A venue outside Belgium answers 422 INVOICE_NOT_AVAILABLE and, if you sent the invoice with the booking, nothing is booked. Once the booking is confirmed, MTEC Games VOF emails the buyer two PDFs: the service fee invoice, and the room invoice issued in the venue's company name (after the game when the room is paid at the venue, and none when a voucher paid the whole room). The PDFs are only emailed, never served through the API.
Send the buyer's details with the booking in an invoice field, or ask afterwards, until 23:59 Brussels time on the 15th of the month after the payment (the game, for a room paid at the venue). An EU business needs its VAT number, which is checked with VIES first. A VIES that does not answer never fails the booking.
curl -X POST https://myescaperoom.eu/api/embed/bookings/BK-ABCD1234/invoice-request \
-H "Authorization: Bearer $MER_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"buyerKind": "business",
"name": "Test Buyer NV",
"vatNumber": "BE0000000097",
"address": "Straat 1",
"postalCode": "1000",
"city": "Brussel",
"country": "BE",
"email": "billing@testbuyer.be"
}'{
"available": true,
"requested": true,
"deadline": "2026-11-15T22:59:59.999Z",
"canRequest": true,
"feeInvoice": "pending",
"roomInvoice": "pending",
"roomAfterGame": false,
"invoices": []
}GET /api/embed/bookings/{ref}/invoicesreturns this state again, with each issued invoice listed ininvoices: itsnumber,kind(service_feeorroom) and amount.pdfPathis alwaysnull.feeInvoiceisnone,pendingorissued.roomInvoiceisnone,pending,issued,venue_issues(the venue invoices the room itself) orvoucher_paid.- One request per booking: a second is
409 INVOICE_ALREADY_REQUESTED. Correct a request withPUT(from your server) until an invoice is issued, after which it is409 INVOICE_ALREADY_ISSUED. - Past the deadline,
422 INVOICE_DEADLINE_PASSED.
Reading your bookings#
List the venue's bookings to mirror them in your own calendar or accounting. They come earliest session first, with from (included) and to (excluded) on the session's start as ISO 8601 with an offset, an optional status (pending, confirmed or cancelled), and the usual limit and offset.
# Bookings whose session starts in November 2026. The + in an offset may be sent as %2B.
curl "https://myescaperoom.eu/api/embed/bookings?from=2026-11-01T00:00:00%2B01:00&to=2026-12-01T00:00:00%2B01:00&status=confirmed&limit=50" \
-H "Authorization: Bearer $MER_API_KEY"Troubleshooting#
| What you see | What it means |
|---|---|
| 401 UNAUTHORIZED on every call | The key is wrong or revoked, or the venue is not live. See API keys. |
| 409 SLOT_UNAVAILABLE | The session was taken or is held by someone else, or it falls in the venue's staffing window and no host has said they can work it. Offer another session. |
| 422 PAYMENT_MODE_NOT_ALLOWED | The room does not accept that mode. Read the room's allowedPaymentModes first. |
| 422 INVALID_TEAM_SIZE | teamSize is outside the room's minPlayers to maxPlayers. |
| 400 PHONE_REQUIRED or PHONE_INVALID | The venue requires a phone number (venue.bookerPhone is required), and none or an invalid one was sent. |
| 503 STRIPE_NOT_CONFIGURED | An online booking that still owes the venue money was requested, and the venue has not finished connecting Stripe. |
| A booking stays pending | The payment was not completed, or Stripe's notification has not arrived yet. Wait until the hold has ended (a booking.confirmed webhook may still arrive until then) before you tell the player it failed. |
| A browser shows a CORS error | The page's origin is not in your Allowed origins (the API answered 403 ORIGIN_NOT_ALLOWED and did nothing), or the key is wrong. Neither answer carries the Access-Control-Allow-Origin header, so the browser hides it. |
| guestName, guestEmail, phone and cancellationReason are null | The key does not have May read guest details. Switch it on for a key that stays on your server. |
The full list of codes is on Authentication & errors.
Related#
- Integration overview: which option to choose.
- API keys: create a key, set origins.
- Authentication & errors: headers, CORS, pagination, retries.
- API reference: Embed and Vouchers: every endpoint.
- Webhooks: be told when a booking is confirmed or cancelled, or a voucher changes.
- Invoicing & VAT profile: the venue's side of invoices.