The Moposa API and webhooks
Get a venue's bookings and enquiries into your own systems: an operations dashboard, a spreadsheet, a property management system, or anything else.
There are two ways in, and most set-ups use both:
- Webhooks. Moposa sends a signed message to your address the moment a booking or enquiry is created, changes, is cancelled or lost. Each message carries the whole record as it is now.
- The read API. Ask Moposa for records whenever you like, all of them or only the ones changed since you last asked.
Everything is read only. Moposa stays the place where each wedding is entered and kept up to date; nothing you send can change it.
Getting access
The venue asks Moposa support to switch the API on. It's free. The venue's team then manages it under Settings, Integrations, API and webhooks in their Moposa dashboard, where they:
- create API keys, each reading bookings, enquiries or both;
- add the webhook addresses to send to, choosing what each one receives;
- decide whether contact details (emails and phone numbers) and the guest list summary are included. Both are off until they tick them;
- see every message sent in the last 30 days and what your side answered, and send any of them again;
- see the key under which each of their shared fields, custom fields and dates arrives.
The records
The API and webhooks send exactly the same record. Four rules shape it:
- The whole record, every time. A webhook carries the booking as it is now, not only what changed, so your side can simply overwrite its copy. A missed or repeated message does no harm.
idis the key. Find your copy by it and update it. It never changes.- Keys are stable. Shared fields (the questions couples answer) are keyed by their insert code, not their title, so the venue renaming a question changes nothing. Custom fields are
custom_1tocustom_15. - The shape is stable. Every field the venue has set up is present. An unanswered question is
nullrather than missing.
version goes up by one with every change, and updated_at is when it last changed, so you can ignore anything older than the copy you hold. Times are ISO 8601 in Irish time with the offset; dates without a time are YYYY-MM-DD.
A booking
{
"object": "booking",
"id": 48213,
"version": 7,
"updated_at": "2026-09-18T11:04:12+01:00",
"type": "wedding",
"status": "booked",
"date": "2027-06-12",
"date_booked": "2026-08-30",
"cancellation": null,
"enquiry_id": 91234,
"names": "Aoife & Cian",
"contacts": {
"primary": { "first_name": "Aoife", "last_name": "Byrne", "email": "aoife@example.com", "mobile": "+353870000000", "phone": null },
"partner": { "first_name": "Cian", "last_name": "Walsh", "email": null, "mobile": null, "phone": null },
"other": null
},
"expected_guests": 140,
"room": { "id": 212, "name": "The Ballroom" },
"package": { "id": 38, "name": "Classic Package" },
"coordinator": { "id": 5521, "name": "Sarah Kelly", "email": "sarah@example.com" },
"source": "weddingsonline",
"source_notes": null,
"labels": ["Day 2"],
"event": null,
"shared_fields": {
"ceremony_location": "Walled Garden",
"ceremony_time": "2pm",
"meal_speeches": "After Meal",
"centrepieces": ["Candelabras Only"],
"styling_lounge": null
},
"shared_fields_updated_at": "2026-09-14T20:31:00+01:00",
"custom_fields": { "custom_1": "On-site", "custom_2": "Yes" },
"dates": { "menu_tasting": "2027-02-11T18:00:00+00:00", "final_details": null },
"guest_list": {
"invited": 152, "attending": 131, "declined": 9, "awaiting": 12,
"adults": 130, "teenagers": 4, "children": 8, "babies": 1,
"dietary": { "vegetarian": 4, "vegan": 1, "dairy_free": 0, "gluten_free": 3, "nut_free": 1, "other": 2 },
"dietary_notes": ["Coeliac", "No shellfish"]
},
"accommodation": {
"rooms_held": "20 rooms Fri and Sat", "room_rate": "€165 B&B",
"online_booking": true, "rooms_allocated": 20, "rooms_booked": 12,
"booking_opens": "2026-10-01", "booking_closes": "2027-05-12"
},
"links": { "dashboard": "https://venue.moposa.com/dashboard/bookings/view/48213" }
}
- contacts: names are always included. Emails and phone numbers only when the venue has switched contact details on for your key or webhook; otherwise those keys are left out. Postal addresses are never sent. A contact with nothing filled in is
null. - shared_fields: the couple's answers, keyed by insert code. A tick-box question is a list.
- dates: keyed by date type, with a time where the type has one. Where a booking has two of a type, the earliest one that isn't done.
- guest_list: counts from the couple's guest list for the wedding day, never names. Only when the venue has switched the guest list summary on, and
nullwhen the couple hasn't started a guest list. Numbers by age and dietary requirements count guests who haven't declined. - accommodation: the rooms held and rate as the venue typed them, plus the room block's figures where the venue uses Moposa's accommodation module (
nullotherwise). - event: for event bookings (conferences, parties) the title, category, set-up style and times;
nullfor weddings.
An enquiry
{
"object": "enquiry",
"id": 91234,
"version": 3,
"updated_at": "2026-09-02T16:40:00+01:00",
"type": "wedding",
"status": "booked",
"enquiry_date": "2026-08-11T14:21:00+01:00",
"date_booked": "2026-08-30T10:02:00+01:00",
"date_lost": null,
"lost": null,
"booking_id": 48213,
"names": "Aoife & Cian",
"contacts": { "primary": { ... }, "partner": { ... }, "other": null },
"preferred_date": { "date": "2027-06-12", "flexible": true, "timeframe": null },
"guests": "about 140",
"room": null,
"coordinator": { "id": 5521, "name": "Sarah Kelly", "email": "sarah@example.com" },
"source": "weddingsonline",
"source_notes": null,
"capture_method": "website",
"attribution": { "traffic_channel": "Paid search", "utm_source": "google", "utm_medium": "cpc",
"utm_campaign": "summer", "utm_term": null, "utm_content": null,
"gclid": "Cj0KCQ...", "landing_page": "https://example.com/weddings", "referrer": null },
"labels": [],
"event": null,
"custom_fields": { "custom_1": "On-site" },
"dates": { "showaround": "2026-08-20T11:00:00+01:00", "provisional": null },
"links": { "dashboard": "https://venue.moposa.com/dashboard/enquiries/view/91234" }
}
status is pending, booked or lost. lost carries the reason and competitor once an enquiry is lost. preferred_date.timeframe is the rough date the couple gave when they had no exact one ("A Saturday in Summer 2027"). guests is free text, as the couple typed it.
Webhooks
Moposa POSTs JSON to your address. Messages are gathered for a minute first, so a couple saving their form and a coordinator correcting two fields becomes one message rather than three.
POST https://your-address.example.com/moposa
Content-Type: application/json
Moposa-Event: booking.updated
Moposa-Delivery: dlv_5f0c2a
Moposa-Signature: t=1726654000,v1=9b1e...c4
{
"id": "evt_8d31f0a2c9e4a1b2",
"type": "booking.updated",
"created_at": "2026-09-18T11:04:12+01:00",
"venue": { "id": 12345, "name": "The Venue" },
"changed": ["expected_guests", "shared_fields.ceremony_time"],
"backfill": false,
"data": { ...the booking record... }
}
Events
booking.created: a booking is added, including when an enquiry is converted.booking.updated: anything in the record changes, whoever changed it.booking.cancelledandbooking.restored.booking.deleted: carries the last record Moposa had, with"deleted": true.enquiry.created,enquiry.updated,enquiry.booked(abooking.createdfollows),enquiry.lost,enquiry.reinstated.enquiry.deleted: deleted, or merged into another enquiry, in which casedata.merged_intosays which.ping: the venue pressed Send test. It carries a real record.
changed lists what changed in this version, as paths into the record. backfill is true when the venue pressed Send all upcoming bookings.
Delivery
- Answer with any 2xx within 15 seconds. Redirects aren't followed (except the one a Google Apps Script web app gives after running).
- The record is read when the message is sent, so a retried message always carries the latest data.
- A message that isn't accepted is tried again after 1 minute, 5 minutes, 15 minutes, 1 hour, 3 hours, 6 hours, 12 hours and 24 hours, nine tries in all, and then kept in the venue's log to send again by hand. An address with no success for three days is paused and the venue emailed.
- Delivery is at least once: occasionally you may get the same message twice.
Moposa-Deliveryidentifies each delivery, and because each carries the whole record, applying it twice does no harm. When the venue presses Send again, the message goes as a new delivery with the same eventid, carrying the record as it is now. - When several changes to one record are waiting to be sent, only the newest message goes, with the changes of the others added to its
changedlist. - Renaming a room, package, coordinator or label changes those names in the records the API returns, but isn't sent as a webhook message for every record it appears on. The record's next change carries the new name.
- While Moposa has the API switched off for a venue, nothing is sent. Changes made in that time are taken in without messages when it's switched back on; the venue can press Send all upcoming bookings to bring a dashboard up to date.
- Addresses must be HTTPS. At most two messages a second go to one address.
Checking the signature
Each webhook has its own secret, shown to the venue in its settings. Moposa-Signature is t= the time it was sent (Unix seconds) and v1= the HMAC-SHA256, in hex, of the time, a full stop and the raw body, made with that secret. Reject a message whose signature doesn't match or whose time is more than five minutes out.
// PHP
$body = file_get_contents('php://input');
preg_match('/t=(\d+),v1=([a-f0-9]{64})/', $_SERVER['HTTP_MOPOSA_SIGNATURE'] ?? '', $m);
$expected = $m ? hash_hmac('sha256', $m[1].'.'.$body, $secret) : '';
if (!$m || !hash_equals($expected, $m[2]) || abs(time() - (int)$m[1]) > 300) {
http_response_code(400);
exit;
}
$event = json_decode($body, true);
// Node.js (use the raw body, before any JSON parsing)
const crypto = require('crypto');
function isFromMoposa(rawBody, header, secret) {
const m = /t=(\d+),v1=([a-f0-9]{64})/.exec(header || '');
if (!m || Math.abs(Date.now() / 1000 - Number(m[1])) > 300) return false;
const expected = crypto.createHmac('sha256', secret).update(m[1] + '.' + rawBody).digest('hex');
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(m[2]));
}
Airtable and Google Apps Script can't read request headers, so they can't check signatures. For those, the long unguessable address is what keeps the webhook private; for Apps Script you can also add your own ?key= to the address and check it in the script, as below. If the signing secret can't be read, Moposa doesn't send unsigned messages: they wait and the venue is shown the error.
The read API
Base address https://api.moposa.com/v1. Send the key the venue gave you in a header with every request:
curl "https://api.moposa.com/v1/bookings?updated_since=2026-09-17T00:00:00Z" \ -H "Authorization: Bearer mpk_3f9a..."
GET /v1/bookings: a page of bookings.GET /v1/bookings/{id}: one booking, brought up to date first.GET /v1/enquiriesandGET /v1/enquiries/{id}: the same for enquiries.GET /v1/fields: every shared field (key, label, section, type and options), labelled custom field and date type, with the key each arrives under.GET /v1/venue: rooms, packages, coordinators,event_categories(the venue's event types) andsetup_styles, for building pick lists.
Every endpoint is GET. Any other method gets 405 with {"error": {"code": "read_only", ...}}.
Lists
updated_since: every record changed at or after this time (ISO 8601), oldest change first. Records deleted since then are included as{"object": "booking", "id": 48213, "deleted": true, "deleted_at": "..."}so you can remove them. When keeping a copy in step, don't addstatus,typeor date filters: they apply to the record as it is now, so a booking that is cancelled while you ask forstatus=bookedwould never come back to tell you.date_from,date_to:YYYY-MM-DD, on the wedding date for bookings and the enquiry date for enquiries.status: booked, cancelled or all for bookings; pending, booked, lost or all for enquiries. All by default.type: wedding or event.limit: 1 to 100, 100 by default.cursor: passnext_cursorback, with the same filters, for the next page.
Without updated_since, bookings come in wedding date order and enquiries newest first. Responses look like {"object": "list", "data": [...], "has_more": true, "next_cursor": "..."}.
Keeping a copy in step: store the latest updated_at you've seen and ask with updated_since set to a few seconds before it. You'll get the records at the boundary again; since you update by id, that's harmless. Most changes reach lists and webhooks within a couple of minutes. A few kinds of change are only noticed by Moposa's regular checks: the couple's guest list, the accommodation room block and some changes made in bulk arrive within 15 minutes for upcoming bookings, and overnight for everything else. A single record fetched by id is always current.
Errors and limits
Errors are {"error": {"code": "...", "message": "..."}} with 401 (missing, wrong or revoked key), 403 (the API is switched off for the venue, or your key doesn't read that), 404, 405, 422 (a parameter isn't valid; the message says which) or 429. Each key may make 60 requests a minute and 10,000 a day; a 429 carries Retry-After. The API only ever gains fields; anything that would break an existing integration will come as a new version.
Airtable
- Make a table with a Moposa ID number field and the columns you want.
- Create an automation with the trigger When webhook received. Copy its address; the venue adds it in Moposa as a webhook and presses Send test, so Airtable learns what a booking looks like.
- Add Find records where Moposa ID is
data.id, then a condition: if a record was found, Update record; otherwise Create record. Mapdata.date,data.names,data.shared_fields.ceremony_timeand so on to your columns. - The venue presses Send all upcoming bookings to fill the table.
Each message counts as one automation run against your Airtable plan. A venue sends a few thousand a year.
Google Sheets
This script keeps one row per booking, found by its Moposa ID and updated in place.
- In the sheet, open Extensions, then Apps Script. Paste in the script below, choose a key of your own, and list the columns you want.
- Press Deploy, New deployment, type Web app, execute as Me, who has access Anyone. Deploy and copy the web app address.
- The venue adds that address in Moposa as a webhook, with
?key=and your key on the end, then presses Send test and Send all upcoming bookings.
// Moposa to Google Sheets: one row per booking, updated in place.
const KEY = 'choose-something-long'; // must match ?key= on the address given to Moposa
const SHEET = 'Weddings'; // the tab to write to
const COLUMNS = [ // your heading, and where it comes from in the booking
['Moposa ID', 'id'], // keep this one first: rows are found by it
['Wedding date', 'date'],
['Couple', 'names'],
['Status', 'status'],
['Guests', 'expected_guests'],
['Room', 'room.name'],
['Ceremony location', 'shared_fields.ceremony_location'],
['Ceremony time', 'shared_fields.ceremony_time'],
['Speeches', 'shared_fields.meal_speeches'],
['Menu tasting', 'dates.menu_tasting'],
['Last changed', 'updated_at'],
];
function doPost(e) {
if (!e || !e.parameter || e.parameter.key !== KEY) return ContentService.createTextOutput('wrong key');
const message = JSON.parse(e.postData.contents);
const record = message.data;
if (!record || record.object !== 'booking') return ContentService.createTextOutput('ignored');
const lock = LockService.getScriptLock();
lock.waitLock(20000);
try {
const sheet = SpreadsheetApp.getActive().getSheetByName(SHEET) || SpreadsheetApp.getActive().insertSheet(SHEET);
if (sheet.getLastRow() === 0) sheet.appendRow(COLUMNS.map(c => c[0]));
const ids = sheet.getLastRow() > 1 ? sheet.getRange(2, 1, sheet.getLastRow() - 1, 1).getValues().map(r => String(r[0])) : [];
const index = ids.indexOf(String(record.id));
if (record.deleted) {
if (index !== -1) sheet.deleteRow(index + 2);
} else {
const row = COLUMNS.map(c => valueAt(record, c[1]));
if (index === -1) sheet.appendRow(row);
else sheet.getRange(index + 2, 1, 1, row.length).setValues([row]);
}
} finally {
lock.releaseLock();
}
return ContentService.createTextOutput('ok');
}
function valueAt(record, path) {
const value = path.split('.').reduce((v, key) => (v === null || v === undefined) ? null : v[key], record);
if (value === null || value === undefined) return '';
return Array.isArray(value) ? value.join(', ') : value;
}
To add a column, add a line to COLUMNS with the key from the venue's field key list, save, and deploy a new version (Deploy, Manage deployments, edit, New version) so the address stays the same.
Google answers every message to a web app the same way, whatever the script does, so Moposa's log shows them all as Delivered even when the script turned one away. If rows don't appear, check the ?key= on the address matches KEY in the script.
Zapier, Make and your own code
Zapier's "Webhooks by Zapier" trigger and Make's custom webhook accept Moposa's messages as they are. For your own service, use webhooks for changes as they happen and the read API to fill a new copy or catch up after downtime.
Help
The venue's delivery log shows exactly what was sent and what your side answered, which settles most "it didn't arrive" questions. For anything else, the venue can contact Moposa support.