Guide to MeasureOne Webhooks: Features and Best Practices
MeasureOne webhooks tell your application when consumer-permissioned data is ready. After an individual connects a payroll provider, an insurer or a school through MeasureOne's Link widget, the extraction and report generation happen in the background, and MeasureOne posts an event to your endpoint when items are available, when a report is complete, or when something goes wrong.
This guide covers how MeasureOne webhooks are configured, the events and payloads they send, how to authenticate them with a static header, how retries work, and the practices that keep a MeasureOne receiver reliable.
What are MeasureOne webhooks?
MeasureOne configures webhooks on each Data Request, not in a dashboard. When you create a Data Request, you pass a delivery_details array, and each entry says where one event goes:
event_type: the event to deliver. If you leave it out, MeasureOne uses your account's default preference.url: the endpoint that receives it.headers: optional static headers sent with the request.x_auth_token_credentials: optional credentials MeasureOne uses to fetch a JWT from your own token endpoint before delivering.
MeasureOne sends a POST with a JSON body and content-type: application/json. The body is flat: the event name in event, the IDs of the Data Request and the individual, and event-specific fields.
MeasureOne doesn't sign webhooks. Its Webhook Security page says MeasureOne "supports JWT and Basic method authorization", with the credentials in the Authorization header. In the documented delivery_details schema, though, the two options are free-form headers and the JWT token credentials. A static header in headers is the one you can check against a stored value.
MeasureOne webhook features
| Feature | Details |
|---|---|
| Configuration | delivery_details on each Data Request (POST /v3/datarequests/new) |
| Method | POST, content-type: application/json |
| Event field | event in the body |
| Authentication | Static headers you choose, or a JWT fetched from your token endpoint. No signature |
| Success | Any 2xx |
| Retries | Up to 10, at 1, 2, 4, 8, 16, 32, 64, 128, 256 and 512 minutes, with jitter |
| Timeout | "The configured timeout", value not published |
| Source IPs | Not published |
Common events
| Area | event | Fires when |
|---|---|---|
| Reports | datarequest.report_, datarequest.report_, datarequest.report_error | The complete report is available, part of it was updated or added, or generating it failed |
| Extraction | datarequest.items_, datarequest.items_, datarequest.no_items | Extraction finished with items, all items are processed, or no item was found |
| Refreshes | datarequest.refresh_ | A scheduled refresh fails |
| Items | item.created, item.processed, item.unavailable | An item is created, processed, or can't be extracted from the datasource |
| Sessions | session.rejected, session.terminated, session.no_login | A Link session can't be processed, is terminated, or is closed without a connection |
| Other | datasource.connected, individual.purged, transaction.processed, unprocessable.entity | A datasource connects, an individual is purged, a services API transaction completes, or a received item can't be processed |
Four more events are still documented but marked deprecated: datarequest.report_ (MeasureOne says to use datarequest.report_ instead), datarequest.updates_, datarequest.acquisition_ and session.completed.
MeasureOne's event pages list fields with per-field examples rather than complete bodies. Put together from the datarequest.report_ page, a body looks like this:
{
"event": "datarequest.report_complete",
"datarequest_id": "dr_TSEne2SSjP3GvpVrs5HLol4xor2",
"individual_id": "idv_TSEne2SSjP3GvpVrs5HLol4xor2",
"external_id": "ABCDECD",
"report_uri": "m1_report://INCOME_SUMMARY",
"is_refresh": false,
"metadata": { "report_id": "REPORT" }
}
report_uri names the report type, such as m1_ or m1_, and you fetch the report itself from the API. Events triggered by a scheduled refresh set is_refresh and carry a refresh_id, and event-based refreshes add monitor_events. Because there is no published complete payload, log the first deliveries of each event before you rely on a field.
Inspect MeasureOne webhooks as they arrive. Put a Hookdeck Inspect URL in
delivery_detailsfor a test Data Request to see the exact body and headers, with no account or setup required.
Setting up MeasureOne webhooks
Pick a header name, such as X-M1-Webhook-Secret, and generate a long random value with openssl rand -hex 32. Then add a delivery_details entry for each event you want when you create the Data Request:
{
"delivery_details": [
{
"event_type": "datarequest.report_complete",
"url": "https://example.com/webhooks/measureone",
"headers": { "X-M1-Webhook-Secret": "<your random value>" }
},
{
"event_type": "datarequest.report_error",
"url": "https://example.com/webhooks/measureone",
"headers": { "X-M1-Webhook-Secret": "<your random value>" }
}
]
}
Put the header on every entry. An entry without it is delivered without the secret, and your receiver will reject it.
The alternative, x_auth_token_credentials, makes MeasureOne fetch a fresh JWT from your token endpoint before every attempt, including retries, and send it in Authorization. That token is issued by your own auth server, so checking it means validating it there. If fetching the token fails, the attempt counts as a failed delivery.
With Hookdeck. Add a static header to delivery_details.headers on the Data Request, then create a Hookdeck source with the MeasureOne type and enter the same name in API Key Header and the exact value in API Key. Hookdeck finds the header case-insensitively and compares the whole value. Use the source URL as each entry's url. The JWT option fetches a new token for every delivery and cannot be checked against a stored value, so use the static header with Hookdeck. The source accepts POST only, which is what MeasureOne sends.
Securing MeasureOne webhooks
Read the header you configured, compare its whole value with the stored one in constant time, and only then parse the body.
const crypto = require('crypto');
const express = require('express');
const HEADER = (process.env.MEASUREONE_WEBHOOK_HEADER || 'X-M1-Webhook-Secret').toLowerCase();
const SECRET = process.env.MEASUREONE_WEBHOOK_SECRET; // the exact, whole header value
function safeEqual(received, expected) {
const a = Buffer.from(received, 'utf8');
const b = Buffer.from(expected, 'utf8');
// timingSafeEqual throws on unequal lengths, so check first.
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
const app = express();
app.post('/webhooks/measureone', express.raw({ type: '*/*' }), (req, res) => {
const received = req.get(HEADER);
if (!SECRET || typeof received !== 'string' || !safeEqual(received, SECRET)) {
return res.sendStatus(401);
}
let event;
try {
event = JSON.parse(req.body.toString('utf8'));
} catch {
return res.sendStatus(400);
}
// Route on event.event, queue the work, then respond.
res.sendStatus(200);
});
The same check in Python:
import hmac
import os
HEADER = os.environ.get("MEASUREONE_WEBHOOK_HEADER", "X-M1-Webhook-Secret")
SECRET = os.environ.get("MEASUREONE_WEBHOOK_SECRET", "")
def verify_measureone_request(headers) -> bool:
if not SECRET:
return False # fail closed when unconfigured
received = headers.get(HEADER) # Starlette and Flask headers are case-insensitive
if not received:
return False
# Compare bytes: compare_digest raises TypeError on a non-ASCII str.
return hmac.compare_digest(received.encode("utf-8"), SECRET.encode("utf-8"))
A static header authenticates the sender, not the body. It can't detect a modified payload, and with no signed timestamp a captured request can be replayed and still pass. Use HTTPS and a long random value. The webhook only tells you something happened, so fetch reports from the MeasureOne API with your API key rather than trusting report data in a request.
Make MeasureOne webhooks production-ready. Hookdeck Event Gateway checks your header on every MeasureOne event, stores each one durably, and delivers it to your service with retries you control.
MeasureOne webhook limitations and pain points
Configuration lives on every Data Request
The Problem: Some Data Requests send no webhooks, or send them without the secret header.
Why It Happens: delivery_details is part of each Data Request, so every code path that creates one has to include the right entries and headers. An entry without event_type falls back to the account default, which may not be what you expect.
Workarounds:
- Build
delivery_detailsin one function and use it everywhere you create Data Requests. - Set
event_typeexplicitly on every entry.
How Hookdeck Can Help: Pointing every entry at one Hookdeck source means a URL change happens in Hookdeck, not in every Data Request you create. Hookdeck's MeasureOne source compares the header you name with the exact value you enter, answers MeasureOne with a 200 either way, and records a request without the right header as failed verification, so it never reaches your destination and is easy to spot.
Long retry tails
The Problem: An event you thought was lost arrives hours later.
Why It Happens: MeasureOne retries failed deliveries up to 10 times with doubling intervals, from one minute up to 512 minutes, so the last attempt comes more than 17 hours after the first.
Workarounds:
- Make handlers idempotent and don't let an older event overwrite newer state.
- Answer with a
2xxas soon as the header checks out.
How Hookdeck Can Help: Hookdeck accepts each delivery when it arrives, so MeasureOne's retries only start if Hookdeck itself is unreachable. Hookdeck delivers to your service with retries on your schedule and lets you replay any event.
No complete example payloads
The Problem: Handlers built from the docs miss fields or expect ones that aren't there.
Why It Happens: MeasureOne documents each event as a field list with per-field examples, not as a complete body.
Workarounds:
- Capture real deliveries in a test environment before you finalise handlers.
- Treat every field except
eventas optional.
How Hookdeck Can Help: Hookdeck stores every request with its headers and body, so you can see what each event actually contains and replay it against your handler while you develop.
Best practices
Route on event
Switch on event and acknowledge anything you don't recognise. Move off the deprecated events, especially datarequest.report_, which MeasureOne says to replace with datarequest.report_.
Treat webhooks as notifications
Fetch reports and items from the MeasureOne API using the IDs in the event. That keeps sensitive data out of webhook logs and means a replayed request can't inject data.
Deduplicate deliveries
There is no documented event ID. Build a key from event, datarequest_id and, for refreshes, refresh_id. See our guide to webhook idempotency.
Acknowledge fast
Answer with a 2xx straight after the header check and do the work afterwards. See why to process webhooks asynchronously.
Conclusion
MeasureOne posts flat JSON events to the URLs in each Data Request's delivery_details, with the event name in event and no signature. Add a static header to every entry and compare its whole value, route on event, fetch the real data from the API, and acknowledge with a 2xx quickly. Failed deliveries come back for up to about 17 hours, so make your handlers idempotent.
Hookdeck Event Gateway sits in front of your receiver, checks the header, stores every event and retries delivery to your service, so nothing depends on your endpoint being up at the moment MeasureOne calls.
Get started with Hookdeck for free and handle MeasureOne webhooks reliably in minutes.