# 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.

Source: https://emailmovers.com/mydatahub/docs/webhooks
Updated: 4 October 2026

MyDataHub sends 11 event types to your HTTPS endpoint, signed with HMAC-SHA256 under the [Standard Webhooks](https://www.standardwebhooks.com/) 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.

> **Not yet live.** MyDataHub v2 is built and tested locally and is not yet live. This page describes the contract the API is tested against, and the launch date is to confirm.

## Register an endpoint

`POST /v1/webhooks` takes an HTTPS `url`, the `events` you want and an optional `description`.

```json
{
  "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](https://emailmovers.com/mydatahub/docs/versions.md).

- 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

| 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](https://emailmovers.com/mydatahub/docs/reference/webhooks.md). Every payload has the same envelope.

```json
{
  "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.

```ts
// 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.

```ts
// 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`.

```bash
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](https://emailmovers.com/mydatahub/docs/reference/webhooks.md).
- Act on `suppression.created` with [Suppressions and objections](https://emailmovers.com/mydatahub/docs/suppressions.md).
- Create the endpoint with [REST reference: Register a webhook endpoint](https://emailmovers.com/mydatahub/docs/reference/rest/create-webhook-endpoint.md).
