Measure MapAPI

Webhooks

Receive an HTTPS request on your server when projects are created or deleted, comments arrive, guests ask for estimates, budgets run low or members join.

A webhook tells your server when something happens in your account: the same moments as the notification emails, sent to a URL as JSON.

Set up an endpoint#

On the Developer page, under Webhooks:

  1. Type your endpoint URL: an https:// address on the internet. Addresses on private networks are refused, and this is checked again before every delivery.
  2. Tick the events you want.
  3. Press Save. The first time, the page shows your signing secret in full: copy it then. Afterwards only its last characters are shown.
  4. Press Send test event and pick an event to check your endpoint.

A test sends a sample of the event, with made-up values and "test": true in its data, even when the event is not ticked or the endpoint is switched off.

What your server receives#

Each event is one POST with a JSON body:

JSON
{
  "id": "evt_3f9a1c2b7d4e5f60718293a4",
  "type": "project.created",
  "created": 1792000000000,
  "data": { "project": { "project_id": "Xr7Lp2Qm9KdT4wZs", "name": "14 Harbour Road" } }
}
FieldMeaning
idUnique for each event. Use it to ignore repeats.
typeThe event, below.
createdWhen it happened, in milliseconds since 1 January 1970 (UTC).
dataWhat happened; its shape depends on the event.

And these headers:

HeaderValue
Content-Typeapplication/json
X-MMO-EventThe event, as type.
X-MMO-DeliveryThe event's id.
X-MMO-Signaturet=<milliseconds>,v1=<hex>: when it was signed, and the signature.
User-AgentMeasureMapOnline-Webhooks/1

Answer quickly#

  • Answer with a 2xx status within 8 seconds. Store the event and do slow work afterwards. Any other status, a redirect or a timeout is a failed delivery.
  • Each event is sent once and never retried. The Developer page shows the last delivery and the failures in a row.
  • After 10 failures in a row the endpoint is switched off, and the account's owner gets an email. Fix your server, then press Save to switch it back on.
  • Now and then an event can arrive twice or out of order: use id to ignore repeats and created to put events in order.

Check the signature#

Anyone who knows your URL can post to it, so check every delivery:

  1. Split X-MMO-Signature at the comma into t and v1.
  2. Compute an HMAC-SHA256 of t, a full stop, and the raw request body, with your signing secret as the key, as lowercase hexadecimal.
  3. Compare it with v1 in constant time.
  4. Reject the delivery if t is more than 5 minutes away from now. t is in milliseconds.

Use the body exactly as it arrived. Parsing the JSON and writing it out again changes it, and the signature stops matching.

Node.js
const crypto = require('crypto');
const express = require('express');
const app = express();

// The raw body, not express.json(): the signature is over the exact bytes.
app.post('/webhooks/measure-map', express.raw({type: 'application/json'}), (req, res) => {
  const header = req.get('X-MMO-Signature') || '';
  const parts = Object.fromEntries(header.split(',').map(p => p.split('=')));
  const expected = crypto.createHmac('sha256', process.env.MMO_WEBHOOK_SECRET)
    .update(`${parts.t}.${req.body}`).digest('hex');
  const fresh = Math.abs(Date.now() - Number(parts.t)) < 5 * 60 * 1000;
  const valid = parts.v1 && parts.v1.length === expected.length
    && crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
  if (!fresh || !valid) {
    return res.sendStatus(400);
  }
  const event = JSON.parse(req.body);
  res.sendStatus(204);           // answer first...
  handleEvent(event);            // ...then do the work
});

Rotate secret on the Developer page replaces the secret at once and shows the new one in full, once. Update your server as soon as you rotate.

Events#

EventSent whenPlans
project.createdA project is created on your account: in the app, through the API or from an embed.Enterprise, Open Guest
project.deletedA project is permanently deleted. Moving one to the trash does not send it.Enterprise, Open Guest
project.comment.addedSomeone comments on one of your projects.Enterprise, Open Guest
guest.project.createdA guest starts a project and their email address is known.Open Guest
guest.measurement.completedA guest finishes measuring and asks you for an estimate.Open Guest
budget.warning_reachedGuest spend this period crosses your warning amount.Open Guest
budget.limit_reachedGuest spend reaches your limit: new guest projects are refused.Open Guest
enterprise.user.createdA seat is filled: an invitation accepted, a member added in the app, through the API or by single sign-on.Enterprise

Field names in webhook data follow the app's storage (project_id, created_on), not the REST API's camelCase.

project.created#

project.deleted carries the same data.

JSON
{
  "project": {
    "project_id": "Xr7Lp2Qm9KdT4wZs",
    "name": "14 Harbour Road",
    "description": "Front and back lawns",
    "access_level": "private",
    "creator_id": "m3K8qZxLpV4NrT2w",
    "created_on": 1792000000000,
    "parent_id": "",
    "client_name": "Alex",
    "total_polygons": 3,
    "total_spots": 1,
    "bounds": null,
    "url": "https://app.measuremaponline.com/app/p/Xr7Lp2Qm9KdT4wZs"
  }
}

project.deleted#

As project.created, with the project as it was when it was deleted.

project.comment.added#

JSON
{
  "project": { "project_id": "Xr7Lp2Qm9KdT4wZs", "name": "14 Harbour Road", "url": "https://app.measuremaponline.com/app/p/Xr7Lp2Qm9KdT4wZs" },
  "comment": { "id": "cm_2Zx8Lq4P", "text": "Could you add the side garden too?", "created_on": 1792000000000 },
  "author": { "uid": "u9Fq2LmT", "name": "Alex", "email": "alex@example.com" }
}

project has the same fields as in project.created.

guest.project.created#

JSON
{
  "project": { "project_id": "Gp4Wz8Rt", "name": "14 Harbour Road", "access_level": "open_guest", "created_on": 1792000000000 },
  "guest": { "name": "Alex", "email": "alex@example.com", "phone": "+34 600 000 000" },
  "source": "https://example.com/quote"
}

source is the page the visitor came from, when it is known.

guest.measurement.completed#

The lead: the guest has measured and asks for your estimate.

JSON
{
  "project": { "project_id": "Gp4Wz8Rt", "name": "14 Harbour Road", "access_level": "open_guest", "created_on": 1792000000000 },
  "guest": { "name": "Alex", "email": "alex@example.com", "phone": "+34 600 000 000" },
  "message": "Please send me a quote for the front lawn.",
  "requested_on": 1792000300000
}

budget.warning_reached and budget.limit_reached#

JSON
{
  "budget": { "currency": "eur", "projected_amount": 120, "warning_amount": 100, "limit_amount": 150 },
  "subscription": { "plan_id": "mm-online-openguest-1", "state": "active", "quantity": 12, "period_end": 1793200000000 }
}

projected_amount is what the period's guest projects come to so far; warning_amount and limit_amount are null when not set. quantity is the period's guest projects.

enterprise.user.created#

JSON
{
  "user": {
    "uid": "m3K8qZxLpV4NrT2w",
    "email": "maria@example.com",
    "full_name": "Maria Lopez",
    "enterprise_uid": "o7Hs2Kd9",
    "enterprise_role": "user",
    "created_on": 1792000000000
  }
}