# Quickstart for developers

> Count, preview, quote and order UK B2B contacts over REST with curl and TypeScript. Counts, previews and quotes need no account.

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

Four requests take you from nothing to delivered records: count, preview, quote and order. The first three cost 0 credits and need no account. The fourth spends credits, so it needs an API key, an `Idempotency-Key` and a person's confirmation or an agent key.

> **Not yet live.** MyDataHub v2 is built and tested locally and is not yet live. The examples use `https://mydatahub.emailmovers.com/v1`, which opens at launch (date to confirm). Until then there is no public endpoint to call, so read the examples as the contract the API is tested against.

## Before you start

- **Base URL:** `https://mydatahub.emailmovers.com/v1`. Every path in these docs starts with `/v1`.
- **Version:** send `MyDataHub-Version: 2027-01-15` to pin behaviour. Leave it out and your key's pinned version applies. See [Versions and reproducibility](https://emailmovers.com/mydatahub/docs/versions.md).
- **Keys:** `mdh_test_` keys return synthetic data and never charge, so build with one. `mdh_live_` keys spend real credits. Send either as `Authorization: Bearer`. See [Authentication](https://emailmovers.com/mydatahub/docs/authentication.md).
- **Errors:** every error is an RFC 9457 problem with a `code` and a `next_step`. See [Errors](https://emailmovers.com/mydatahub/docs/errors.md).

## 1. Count

A count takes an audience and returns Total, New to you and Emailable, with breakdowns and a content-addressed id. Counts need no key. This audience is "marketing contacts at manager level and above, at manufacturing companies with 50 or more staff, within 20 miles of Leeds".

Leeds is ambiguous, so resolve the place first. `POST /v1/locations/resolve` is free and returns every candidate with a ready filter.

```bash
curl -X POST "https://mydatahub.emailmovers.com/v1/locations/resolve" \
  -H "Content-Type: application/json" \
  --data '{ "text": "20 miles of Leeds" }'
```

The response has `ambiguous` true, a `question` and one candidate per place that fits. The first is the metropolitan district, with the code `E08000035`.

```json
{
  "kind": "radius",
  "code": "E08000035",
  "label": "Within 20 miles of Leeds (metropolitan district, West Yorkshire)",
  "filter": {
    "field": "site.location",
    "op": "within_radius",
    "centre": { "place": "Leeds", "code": "E08000035" },
    "radius_miles": 20
  }
}
```

An audience that names Leeds without a `code` is refused with `422 validation-failed` and the candidate codes in `did_you_mean`. The API never chooses a place silently. Now count with the pinned centre.

```bash
curl -X POST "https://mydatahub.emailmovers.com/v1/counts" \
  -H "Content-Type: application/json" \
  --data '{
  "audience": {
    "schema": "mydatahub.audience/1",
    "dataset": "uk_contacts",
    "where": {
      "all": [
        { "field": "contact.job_function", "op": "in", "values": ["marketing"] },
        { "field": "contact.job_level_rank", "op": "in", "values": [1, 2, 3, 4, 5, 6, 8, 9] },
        { "field": "company.sic_2007", "op": "in", "values": ["C"] },
        { "field": "company.employee_band", "op": "gte", "value": "50-99" },
        { "field": "site.location", "op": "within_radius", "centre": { "place": "Leeds", "code": "E08000035" }, "radius_miles": 20 }
      ]
    }
  },
  "level": "contact",
  "breakdown_by": ["contact.job_level_rank"]
}'
```

> **Note.** Free text job titles, the `contact.title` filter with `matches`, need a free account. An anonymous request that uses one gets `401 account-required`. Anonymous audiences use `contact.job_function`, `contact.title_family` or `contact.job_level_rank`, as above. Send an API key to use a title.

The response starts like this. The numbers are an illustration, not a live count.

```json
{
  "id": "cnt_7K2PQ4MAXN3R6TBY5WDZ",
  "dataset": "uk_contacts",
  "dataset_version": "2026-W39",
  "credits_per_record": 1,
  "contacts": 12840,
  "companies": 3115,
  "emailable": 12310,
  "new_to_you": null
}
```

`new_to_you` is `null` because the request was anonymous. Anonymous callers also see counts under 100 and every breakdown cell rounded to the nearest 5.

The `ETag` of the response equals the count id, and `GET /v1/counts/{id}` replays it for 12 months. See [Counts and breakdowns](https://emailmovers.com/mydatahub/docs/counts.md).

Do not know the filters yet? Send a plain English brief to `POST /v1/audiences/compile` and you get the audience, a readback and a list of questions to answer.

```bash
curl -X POST "https://mydatahub.emailmovers.com/v1/audiences/compile" \
  -H "Content-Type: application/json" \
  --data '{ "brief": "300 finance directors at law firms in Manchester" }'
```

## 2. Preview

A preview returns up to 25 masked rows, each with a "why it matches" list and a provenance card. It takes exactly one of `audience` or `count_id`.

```bash
curl -X POST "https://mydatahub.emailmovers.com/v1/previews" \
  -H "Content-Type: application/json" \
  --data '{ "count_id": "cnt_7K2PQ4MAXN3R6TBY5WDZ", "limit": 10 }'
```

The same audience returns the same rows, because the sample seed is fixed per audience. No rows come back when the count is under 25. See [Samples](https://emailmovers.com/mydatahub/docs/samples.md).

## 3. Quote

A machine quote prices a count for 24 hours and is bound to a dataset version. It tells you the records after overlap and suppression, the credits, the price and the licence.

```bash
curl -X POST "https://mydatahub.emailmovers.com/v1/quotes" \
  -H "Content-Type: application/json" \
  --data '{
  "count_id": "cnt_7K2PQ4MAXN3R6TBY5WDZ",
  "field_set": "standard",
  "limit": 5000,
  "dedupe_against": "previous_orders",
  "licence": "multi_use_12m"
}'
```

```json
{
  "id": "qte_01JB4M2R6T1Y5W8ZAK8Q2V7XN3",
  "kind": "machine",
  "records": 5000,
  "removed": { "already_held": 312, "suppressed": 41 },
  "credits_required": 5000,
  "credits_available": 6200,
  "gbp_ex_vat": 1495,
  "expires_at": "2026-09-27T10:14:00Z",
  "blockers": []
}
```

Removed records are never charged. A non-empty `blockers` list says why the quote cannot be ordered yet, such as `insufficient_credits`. See [Quotes and orders](https://emailmovers.com/mydatahub/docs/quotes-and-orders.md).

## 4. Order

An order spends credits against a live `quote_id`. It needs three things beyond the quote.

1. An API key with the `orders:write` scope.
2. An `Idempotency-Key` header, so a retry never charges twice.
3. A person's confirmation, or an agent key whose per-order cap and monthly budget cover the quote.

```bash
curl -X POST "https://mydatahub.emailmovers.com/v1/orders" \
  -H "Authorization: Bearer $MYDATAHUB_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  --data '{ "quote_id": "qte_01JB4M2R6T1Y5W8ZAK8Q2V7XN3", "name": "Leeds marketing Q4", "delivery": ["api", "csv"] }'
```

With a standard key and no confirmation the answer is `403 confirmation-required` and nothing is charged. The problem carries an `approval_url` on mydatahub.emailmovers.com. A signed-in person approves the spend there, which returns a `confirmation_token` bound to that person and this quote.

Send it as `confirmation_token` in the order body. An agent key within its limits places the order directly. See [Spending safely](https://emailmovers.com/mydatahub/docs/spending-safely.md).

A successful order answers `202` with status `queued`. Wait for the `order.ready` webhook, or poll `GET /v1/orders/{id}` until `status` is `ready`.

## 5. Fetch the records

Records come in pages of up to 500 as JSON or CSV, with the licence on every page.

```bash
curl "https://mydatahub.emailmovers.com/v1/orders/ord_01JB4N9S3W2X6Y7Z8A1B2C3D4E/records?limit=100" \
  -H "Authorization: Bearer $MYDATAHUB_API_KEY"
```

The response is `{data, has_more, next_cursor}`. Pass `next_cursor` as `cursor` for the next page.

Add `format=csv` for CSV, or `GET /v1/orders/{id}/download` for a 302 to a single-use signed URL that lives 5 minutes. Fetching again under licence costs 0 credits.

## The whole flow in TypeScript

This client needs Node 22 or later and no packages. Save it as `mydatahub.ts`. It sends the version header, parses problems and keeps the key out of the URL.

```ts
// mydatahub.ts
const BASE = process.env.MYDATAHUB_API_URL ?? "https://mydatahub.emailmovers.com/v1";
const KEY = process.env.MYDATAHUB_API_KEY;

export interface Problem {
  type: string;
  title: string;
  status: number;
  code: string;
  detail: string;
  next_step: string;
  did_you_mean?: string[];
  [member: string]: unknown;
}

export class MyDataHubError extends Error {
  constructor(readonly problem: Problem) {
    super(`${problem.code}: ${problem.detail} ${problem.next_step}`);
  }
}

interface CallOptions {
  body?: unknown;
  /** Send the API key. Counts, previews and quotes work without it. */
  auth?: boolean;
  idempotencyKey?: string;
}

export async function call<T>(method: string, path: string, options: CallOptions = {}): Promise<T> {
  const headers: Record<string, string> = {
    Accept: "application/json",
    "MyDataHub-Version": "2027-01-15",
  };
  if (options.body !== undefined) headers["Content-Type"] = "application/json";
  if (options.auth && KEY) headers.Authorization = `Bearer ${KEY}`;
  if (options.idempotencyKey) headers["Idempotency-Key"] = options.idempotencyKey;
  const response = await fetch(`${BASE}${path}`, {
    method,
    headers,
    body: options.body === undefined ? undefined : JSON.stringify(options.body),
  });
  const text = await response.text();
  const data: unknown = text === "" ? null : JSON.parse(text);
  if (!response.ok) throw new MyDataHubError(data as Problem);
  return data as T;
}
```

Then a script that counts, quotes, orders and reads every page. The types are the few members the script reads. Generate full types from the [OpenAPI document](https://emailmovers.com/mydatahub/docs/reference/openapi.json) with any OpenAPI 3.1 generator.

```ts
// script.ts
import { randomUUID } from "node:crypto";
import { call, MyDataHubError } from "./mydatahub";

interface Count {
  id: string;
  contacts: number;
  companies: number;
  emailable: number;
}
interface Quote {
  id: string;
  records: number;
  credits_required: number;
  gbp_ex_vat: number;
  blockers: { code: string; detail: string }[];
}
interface Order {
  id: string;
  status: "queued" | "building" | "verifying" | "ready" | "failed" | "cancelled";
  credits_charged: number;
}
interface RecordsPage {
  data: { id: string; email: { address: string } }[];
  has_more: boolean;
  next_cursor: string | null;
}

const audience = {
  schema: "mydatahub.audience/1",
  dataset: "uk_contacts",
  where: {
    all: [
      { field: "contact.job_function", op: "in", values: ["marketing"] },
      { field: "contact.job_level_rank", op: "in", values: [1, 2, 3, 4, 5, 6, 8, 9] },
      { field: "company.sic_2007", op: "in", values: ["C"] },
      { field: "company.employee_band", op: "gte", value: "50-99" },
      {
        field: "site.location",
        op: "within_radius",
        centre: { place: "Leeds", code: "E08000035" },
        radius_miles: 20,
      },
    ],
  },
};

const count = await call<Count>("POST", "/counts", { body: { audience } });
console.log(`${count.contacts} contacts at ${count.companies} companies, ${count.emailable} emailable`);

const quote = await call<Quote>("POST", "/quotes", {
  auth: true,
  body: { count_id: count.id, field_set: "standard", limit: 500 },
});
console.log(`${quote.records} records for ${quote.credits_required} credits (GBP ${quote.gbp_ex_vat} ex VAT)`);
if (quote.blockers.length > 0) throw new Error(quote.blockers.map((b) => b.detail).join(" "));

let order: Order;
try {
  order = await call<Order>("POST", "/orders", {
    auth: true,
    idempotencyKey: randomUUID(),
    body: { quote_id: quote.id, delivery: ["api"] },
  });
} catch (error) {
  if (error instanceof MyDataHubError && error.problem.code === "confirmation-required") {
    console.log(`A person must approve this spend at ${String(error.problem.approval_url)}`);
    process.exit(0);
  }
  throw error;
}

while (order.status === "queued" || order.status === "building" || order.status === "verifying") {
  await new Promise((resolve) => setTimeout(resolve, 2000));
  order = await call<Order>("GET", `/orders/${order.id}`, { auth: true });
}
if (order.status !== "ready") throw new Error(`Order ${order.id} ended as ${order.status}`);

let cursor: string | null = null;
do {
  const query: string = cursor ? `&cursor=${encodeURIComponent(cursor)}` : "";
  const page: RecordsPage = await call<RecordsPage>("GET", `/orders/${order.id}/records?limit=100${query}`, {
    auth: true,
  });
  for (const record of page.data) console.log(record.id, record.email.address);
  cursor = page.has_more ? page.next_cursor : null;
} while (cursor);
```

Run it with `MYDATAHUB_API_KEY` set to a test key and `npx tsx script.ts`. A standard key stops at the confirmation step by design, and an agent key within its limits runs through to the records.

## Next steps

- Learn the filters in [The audience definition](https://emailmovers.com/mydatahub/docs/audience.md).
- Handle spend, retries and budgets with [Spending safely](https://emailmovers.com/mydatahub/docs/spending-safely.md).
- Receive `order.ready` instead of polling with [Webhooks](https://emailmovers.com/mydatahub/docs/webhooks.md).
- Browse every operation in the [REST reference](https://emailmovers.com/mydatahub/docs/reference/rest.md).
