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:
- Type your endpoint URL: an
https://address on the internet. Addresses on private networks are refused, and this is checked again before every delivery. - Tick the events you want.
- Press Save. The first time, the page shows your signing secret in full: copy it then. Afterwards only its last characters are shown.
- 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:
{
"id": "evt_3f9a1c2b7d4e5f60718293a4",
"type": "project.created",
"created": 1792000000000,
"data": { "project": { "project_id": "Xr7Lp2Qm9KdT4wZs", "name": "14 Harbour Road" } }
}| Field | Meaning |
|---|---|
id | Unique for each event. Use it to ignore repeats. |
type | The event, below. |
created | When it happened, in milliseconds since 1 January 1970 (UTC). |
data | What happened; its shape depends on the event. |
And these headers:
| Header | Value |
|---|---|
Content-Type | application/json |
X-MMO-Event | The event, as type. |
X-MMO-Delivery | The event's id. |
X-MMO-Signature | t=<milliseconds>,v1=<hex>: when it was signed, and the signature. |
User-Agent | MeasureMapOnline-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
idto ignore repeats andcreatedto put events in order.
Check the signature#
Anyone who knows your URL can post to it, so check every delivery:
- Split
X-MMO-Signatureat the comma intotandv1. - Compute an HMAC-SHA256 of
t, a full stop, and the raw request body, with your signing secret as the key, as lowercase hexadecimal. - Compare it with
v1in constant time. - Reject the delivery if
tis more than 5 minutes away from now.tis in milliseconds.
Use the body exactly as it arrived. Parsing the JSON and writing it out again changes it, and the signature stops matching.
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
});
import hashlib, hmac, json, os, time
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["MMO_WEBHOOK_SECRET"].encode()
@app.post("/webhooks/measure-map")
def measure_map_webhook():
parts = dict(p.split("=", 1) for p in request.headers.get("X-MMO-Signature", "").split(",") if "=" in p)
body = request.get_data() # the raw bytes
expected = hmac.new(SECRET, f"{parts.get('t', '')}.".encode() + body, hashlib.sha256).hexdigest()
fresh = abs(time.time() * 1000 - int(parts.get("t", "0"))) < 5 * 60 * 1000
if not fresh or not hmac.compare_digest(parts.get("v1", ""), expected):
abort(400)
event = json.loads(body)
# Store the event, answer, and process it afterwards.
return "", 204
<?php
$body = file_get_contents('php://input'); // the raw body
$parts = [];
foreach (explode(',', $_SERVER['HTTP_X_MMO_SIGNATURE'] ?? '') as $part) {
[$k, $v] = array_pad(explode('=', $part, 2), 2, '');
$parts[$k] = $v;
}
$expected = hash_hmac('sha256', ($parts['t'] ?? '') . '.' . $body, getenv('MMO_WEBHOOK_SECRET'));
$fresh = abs(microtime(true) * 1000 - (int)($parts['t'] ?? 0)) < 5 * 60 * 1000;
if (!$fresh || !hash_equals($expected, $parts['v1'] ?? '')) {
http_response_code(400);
exit;
}
$event = json_decode($body, true);
http_response_code(204);
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#
| Event | Sent when | Plans |
|---|---|---|
project.created | A project is created on your account: in the app, through the API or from an embed. | Enterprise, Open Guest |
project.deleted | A project is permanently deleted. Moving one to the trash does not send it. | Enterprise, Open Guest |
project.comment.added | Someone comments on one of your projects. | Enterprise, Open Guest |
guest.project.created | A guest starts a project and their email address is known. | Open Guest |
guest.measurement.completed | A guest finishes measuring and asks you for an estimate. | Open Guest |
budget.warning_reached | Guest spend this period crosses your warning amount. | Open Guest |
budget.limit_reached | Guest spend reaches your limit: new guest projects are refused. | Open Guest |
enterprise.user.created | A 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.
{
"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#
{
"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#
{
"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.
{
"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#
{
"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#
{
"user": {
"uid": "m3K8qZxLpV4NrT2w",
"email": "maria@example.com",
"full_name": "Maria Lopez",
"enterprise_uid": "o7Hs2Kd9",
"enterprise_role": "user",
"created_on": 1792000000000
}
}