Start here
Quickstart for developers
Count, preview, quote and order UK B2B contacts over REST with curl and TypeScript. Counts, previews and quotes need no account.
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.
Before you start
- Base URL:
https://mydatahub.emailmovers.com/v1. Every path in these docs starts with/v1. - Version: send
MyDataHub-Version: 2027-01-15to pin behaviour. Leave it out and your key's pinned version applies. See Versions and reproducibility. - Keys:
mdh_test_keys return synthetic data and never charge, so build with one.mdh_live_keys spend real credits. Send either asAuthorization: Bearer. See Authentication. - Errors: every error is an RFC 9457 problem with a
codeand anext_step. See Errors.
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.
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.
{
"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.
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"]
}'The response starts like this. The numbers are an illustration, not a live count.
{
"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.
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.
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.
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.
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.
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"
}'{
"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.
4. Order
An order spends credits against a live quote_id. It needs three things beyond the quote.
- An API key with the
orders:writescope. - An
Idempotency-Keyheader, so a retry never charges twice. - A person's confirmation, or an agent key whose per-order cap and monthly budget cover the quote.
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.
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.
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.
// 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 with any OpenAPI 3.1 generator.
// 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.
- Handle spend, retries and budgets with Spending safely.
- Receive
order.readyinstead of polling with Webhooks. - Browse every operation in the REST reference.