For developers

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:

  1. 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.
  2. id is the key. Find your copy by it and update it. It never changes.
  3. 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_1 to custom_15.
  4. The shape is stable. Every field the venue has set up is present. An unanswered question is null rather 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 null when 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 (null otherwise).
  • event: for event bookings (conferences, parties) the title, category, set-up style and times; null for 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.cancelled and booking.restored.
  • booking.deleted: carries the last record Moposa had, with "deleted": true.
  • enquiry.created, enquiry.updated, enquiry.booked (a booking.created follows), enquiry.lost, enquiry.reinstated.
  • enquiry.deleted: deleted, or merged into another enquiry, in which case data.merged_into says 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-Delivery identifies 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 event id, 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 changed list.
  • 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/enquiries and GET /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) and setup_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 add status, type or date filters: they apply to the record as it is now, so a booking that is cancelled while you ask for status=booked would 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: pass next_cursor back, 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

  1. Make a table with a Moposa ID number field and the columns you want.
  2. 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.
  3. Add Find records where Moposa ID is data.id, then a condition: if a record was found, Update record; otherwise Create record. Map data.date, data.names, data.shared_fields.ceremony_time and so on to your columns.
  4. 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.

  1. 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.
  2. Press Deploy, New deployment, type Web app, execute as Me, who has access Anyone. Deploy and copy the web app address.
  3. 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.

Ready to transform your wedding business?

Join venues across Ireland and the UK who trust Moposa. We'll walk through your set-up, your team and your couples, and show what Moposa would look like for you.

Book a demo See features
What a demo looks like
Duration30 minutes
Set-up helpIncluded
CommitmentNone