Platform
Webhooks
Get told when an order is ready, credits run low or an objection reaches your records. Deliveries follow Standard Webhooks, with signed headers and retries.
MyDataHub sends 11 event types to your HTTPS endpoint, signed with HMAC-SHA256 under the Standard Webhooks scheme, so you can stop polling. A delivery carries three headers and a JSON body. Any 2xx response acknowledges it, and anything else is retried with backoff for about a day.
Register an endpoint
POST /v1/webhooks takes an HTTPS url, the events you want and an optional description.
{
"url": "https://agent.example.com/mydatahub/events",
"events": ["order.ready", "credits.low", "suppression.created"],
"description": "SDR agent"
}The response carries the endpoint and its signing secret, whsec_ followed by base64. The secret is shown once, so store it now.
The endpoint is pinned to the MyDataHub-Version of the request that made it. See Versions and reproducibility.
- The URL must be
httpswith a domain name and no user name in it. It follows no redirects, and a 10 second timeout applies. - A host that resolves to a private, loopback, link-local or reserved address is refused when the connection is made.
- An organisation may hold 20 endpoints.
POST /v1/webhooks/{id}/testsends one signed test event of typewebhook.test. It is not an event type, is not in the log and cannot be subscribed to.- Rotating a secret keeps the old one signing beside the new one until its grace period ends, so a receiver holding either verifies.
Event types
| Event | Fires when |
|---|---|
order.ready | Records are available. |
order.failed | Processing failed; credits are refunded. |
audience.refreshed | A saved audience was recounted, with the diff. |
credits.low | The balance crossed the configured threshold. |
credits.topped_up | A top-up settled. |
warranty.replacement_issued | A bounced or job-changed record was replaced, or re-credited. |
suppression.created | An objection reached a record your organisation holds. Suppress it before any send. |
push.completed | A CRM push finished. |
push.partial | A CRM push partly finished; retry the failed records. |
push.failed | A CRM push failed. |
connection.expired | A CRM token failed; reconnect in the app. |
The payload of each is on Webhook events. Every payload has the same envelope.
{
"id": "evt_01JB4NB7T1Y5W8ZAK8Q2V7XN3M",
"type": "order.ready",
"timestamp": "2026-09-26T10:16:04Z",
"api_version": "2027-01-15",
"data": {
"order_id": "ord_01JB4N9S3W2X6Y7Z8A1B2C3D4E",
"records": 5000,
"records_url": "/v1/orders/ord_01JB4N9S3W2X6Y7Z8A1B2C3D4E/records",
"receipt_url": "/v1/orders/ord_01JB4N9S3W2X6Y7Z8A1B2C3D4E/receipt"
}
}Ignore members you do not know. A later MyDataHub-Version may add some.
Headers
| Header | Meaning |
|---|---|
webhook-id | The event id, evt_.... It is your idempotency key |
webhook-timestamp | Unix seconds when the delivery was signed |
webhook-signature | One or more space separated signatures, each v1, and a base64 HMAC-SHA256 |
The signature is over {webhook-id}.{webhook-timestamp}.{body}, with the raw body exactly as received. The key is the base64-decoded part of the secret after whsec_. Reject a timestamp more than 5 minutes from your clock, and compare signatures in constant time.
Verify a delivery
This is a complete verifier with no packages. Save it as webhook-verify.ts. Pass it the raw body string, not a parsed and re-serialised one.
// webhook-verify.ts
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 300;
export function verifyWebhook(
secret: string,
headers: Record<string, string | undefined>,
rawBody: string,
nowSeconds: number = Math.floor(Date.now() / 1000),
): boolean {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const signatures = headers["webhook-signature"];
if (!id || !timestamp || !signatures) return false;
const seconds = Number(timestamp);
if (!Number.isInteger(seconds)) return false;
if (Math.abs(nowSeconds - seconds) > TOLERANCE_SECONDS) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const digest = createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest("base64");
const expected = Buffer.from(`v1,${digest}`);
return signatures.split(" ").some((candidate) => {
const given = Buffer.from(candidate);
return given.length === expected.length && timingSafeEqual(given, expected);
});
}A receiver then reads the raw body, verifies, treats webhook-id as the idempotency key and answers quickly.
// receiver.ts
import { createServer } from "node:http";
import { verifyWebhook } from "./webhook-verify";
const secret = process.env.MYDATAHUB_WEBHOOK_SECRET ?? "";
const seen = new Set<string>();
createServer(async (request, response) => {
const chunks: Buffer[] = [];
for await (const chunk of request) chunks.push(chunk as Buffer);
const body = Buffer.concat(chunks).toString("utf8");
const header = (name: string): string | undefined => {
const value = request.headers[name];
return Array.isArray(value) ? value.join(" ") : value;
};
const headers = {
"webhook-id": header("webhook-id"),
"webhook-timestamp": header("webhook-timestamp"),
"webhook-signature": header("webhook-signature"),
};
if (!verifyWebhook(secret, headers, body)) {
response.writeHead(400).end();
return;
}
const event = JSON.parse(body) as { id: string; type: string; data: unknown };
if (!seen.has(event.id)) {
seen.add(event.id);
console.log(`${event.type} ${event.id}`);
}
response.writeHead(200).end();
}).listen(8080);Keep the set of seen ids in a database in production. A retry sends the same webhook-id with a fresh timestamp and signature.
Retries
Any 2xx acknowledges. A 410 disables the endpoint. Anything else, a timeout included, is retried with the same webhook-id and a new timestamp and signature.
The OpenAPI document and the specification say retries run over 24 hours with backoff. The current build makes 8 attempts over about 27 hours (to confirm which figure is published).
The first is immediate, and the waits after it are 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours and 10 hours twice. The schedule is stored with each delivery, so it survives a restart.
Replay the last 30 days
GET /v1/events returns events oldest first in the same envelope, for 30 days. Use since and type to catch up after downtime, then dedupe on id.
curl "https://mydatahub.emailmovers.com/v1/events?since=2026-09-26T10:00:00Z&type=order.ready" \
-H "Authorization: Bearer $MYDATAHUB_API_KEY"What to subscribe to
| You want to | Subscribe to |
|---|---|
| Stop polling for an order | order.ready and order.failed |
| Be warned before an agent runs dry | credits.low |
| Stay clear of objections | suppression.created |
| Watch CRM pushes | push.completed, push.partial, push.failed and connection.expired |
| React to a saved audience changing | audience.refreshed |
A test order, made with an mdh_test_ key, announces no events, because an endpoint belongs to your organisation and not to a mode.
Next steps
- Read the payload of every event in Webhook events.
- Act on
suppression.createdwith Suppressions and objections. - Create the endpoint with REST reference: Register a webhook endpoint.