EmailmoversMyDataHub docs
Browse the docs

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.

Updated 4 October 2026View as Markdown

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 https with 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}/test sends one signed test event of type webhook.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

EventFires when
order.readyRecords are available.
order.failedProcessing failed; credits are refunded.
audience.refreshedA saved audience was recounted, with the diff.
credits.lowThe balance crossed the configured threshold.
credits.topped_upA top-up settled.
warranty.replacement_issuedA bounced or job-changed record was replaced, or re-credited.
suppression.createdAn objection reached a record your organisation holds. Suppress it before any send.
push.completedA CRM push finished.
push.partialA CRM push partly finished; retry the failed records.
push.failedA CRM push failed.
connection.expiredA 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

HeaderMeaning
webhook-idThe event id, evt_.... It is your idempotency key
webhook-timestampUnix seconds when the delivery was signed
webhook-signatureOne 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 toSubscribe to
Stop polling for an orderorder.ready and order.failed
Be warned before an agent runs drycredits.low
Stay clear of objectionssuppression.created
Watch CRM pushespush.completed, push.partial, push.failed and connection.expired
React to a saved audience changingaudience.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