# MyDataHub developer docs: full text > UK B2B contact counts, quotes and lists, by Emailmovers. The guides, the problem types and the reference indexes in one file. The page for each REST operation and MCP tool is linked from https://emailmovers.com/mydatahub/docs/reference/rest and https://emailmovers.com/mydatahub/docs/reference/mcp, each with a Markdown twin. --- # MyDataHub developer docs > Count, preview and quote UK B2B contacts for free, then order by REST, MCP or the app. One credit buys one complete UK record. Source: https://emailmovers.com/mydatahub/docs/ Updated: 16 October 2026 MyDataHub by Emailmovers is the UK B2B count engine. Counts, breakdowns, masked samples and quotes cost 0 credits and need no account. You pay only when you order records, at 1 credit per complete UK record, at the same price in the app, the REST API and the MCP server. > **Not yet live.** MyDataHub v2 is built and tested locally and is not yet live. Its host, mydatahub.emailmovers.com, opens at launch with these docs, date to confirm. Today https://mcp.mydatahub.uk and https://app.mydatahub.uk serve the current product, whose 13 MCP tools are replaced by the 22 described here. ## Choose your starting point | You are | Start with | What you do | | --- | --- | --- | | Using the app | [Quickstart for people](https://emailmovers.com/mydatahub/docs/quickstart/people.md) | Describe an audience, read the count and a masked sample, order | | Writing code | [Quickstart for developers](https://emailmovers.com/mydatahub/docs/quickstart/developers.md) | Count, quote and order with curl and TypeScript | | Building or running an agent | [Quickstart for agents](https://emailmovers.com/mydatahub/docs/quickstart/agents.md) | Connect Claude, ChatGPT or Cursor to the MCP server | | Using today's 13 MCP tools or `RunExtract` | [Migration guide](https://emailmovers.com/mydatahub/docs/migrate.md) | Map every old call to its replacement | ## The rules that never change 1. **Looking is free.** The filter catalogue, title expansion, location resolution, counts, breakdowns, masked samples, machine quotes and plans cost 0 credits and need no account. 2. **One credit, one record.** A credit buys one complete UK record. Duplicates, suppressed records, records you already hold and failed lookups cost 0. 3. **Quote, then confirm.** A spend needs a machine `quote_id` plus a person's confirmation or an agent key within its limits. A flag set by a model is never consent. See [Spending safely](https://emailmovers.com/mydatahub/docs/spending-safely.md). 4. **One audience definition.** Chips in the app, a brief compiled by an agent and a JSON document sent over REST all produce the same count, the same breakdown and the same id. See [The audience definition](https://emailmovers.com/mydatahub/docs/audience.md). 5. **Counts replay.** A count id is content addressed and re-runs exactly for 12 months on its pinned dataset version. See [Versions and reproducibility](https://emailmovers.com/mydatahub/docs/versions.md). ## What is behind the counts On 26 September 2026 the database held 9,028,587 records at active companies. Of those, 1,660,190 had an email address, 1,605,275 had a named-person email and 1,277,443 of those were at Manager level or above. Counts for the same audience are identical in the app, the API and the MCP server. ## Where things live | Surface | Address | Status | | --- | --- | --- | | App | https://mydatahub.emailmovers.com | Not yet live. Today's product is at https://app.mydatahub.uk | | REST API | https://mydatahub.emailmovers.com/v1 | Not yet live | | MCP server | https://mydatahub.emailmovers.com/mcp | Not yet live. Today https://mcp.mydatahub.uk serves 13 tools. It keeps serving today's product after launch, until the new server supports OAuth sign-in from the client, and then passes MCP through to it | | OAuth 2.1 issuer | https://mydatahub.emailmovers.com/id | Not yet live. At launch it signs you in to the app; REST and MCP clients use an API key, and OAuth sign-in for them is planned for after launch | | These docs | https://emailmovers.com/mydatahub/docs/ | Built, not yet published | ## For agents reading this site Every page has a Markdown twin at the same path with `.md`, such as `/quickstart/developers.md`. The index is [llms.txt](https://emailmovers.com/mydatahub/docs/llms.txt), and the guides, problem types and reference indexes are in one file at [llms-full.txt](https://emailmovers.com/mydatahub/docs/llms-full.txt). The OpenAPI 3.1 document is at [/reference/openapi.json](https://emailmovers.com/mydatahub/docs/reference/openapi.json). ## All guides - [Audience definition](https://emailmovers.com/mydatahub/docs/audience.md): the JSON document every count starts from. - [Counts and breakdowns](https://emailmovers.com/mydatahub/docs/counts.md): Total, New to you, Emailable, and what disclosure control does to small numbers. - [Samples](https://emailmovers.com/mydatahub/docs/samples.md): masked previews with why each record matches. - [Quotes and orders](https://emailmovers.com/mydatahub/docs/quotes-and-orders.md): machine quotes, orders, records and credits. - [Spending safely](https://emailmovers.com/mydatahub/docs/spending-safely.md): the three layers that guard every spend. - [Authentication](https://emailmovers.com/mydatahub/docs/authentication.md): API keys, agent keys and scopes, and OAuth 2.1, which signs you in to the app. - [Errors](https://emailmovers.com/mydatahub/docs/errors.md): every problem type and what to do next. - [Rate limits](https://emailmovers.com/mydatahub/docs/rate-limits.md): limits by tier and the headers that report them. - [Webhooks](https://emailmovers.com/mydatahub/docs/webhooks.md): Standard Webhooks signing, retries and event types. - [Suppressions and objections](https://emailmovers.com/mydatahub/docs/suppressions.md): lists, checks before a send and database-wide objections. - [Compliance receipts](https://emailmovers.com/mydatahub/docs/compliance-receipts.md): the signed receipt for every order and how to verify it. - [Versions and reproducibility](https://emailmovers.com/mydatahub/docs/versions.md): dated API versions, dataset versions and count ids. - [Changelog and deprecation policy](https://emailmovers.com/mydatahub/docs/changelog.md): what changed and how long old things last. - [Reference](https://emailmovers.com/mydatahub/docs/reference.md): every REST operation and MCP tool, generated from the contracts. --- # Quickstart for people > Run a count, read a masked sample and place an order in the MyDataHub app. The target is a first count in under 60 seconds and a first order in under 5 minutes. Source: https://emailmovers.com/mydatahub/docs/quickstart/people Updated: 4 October 2026 You can run a first count at mydatahub.emailmovers.com/counts without an account, and the target is under 60 seconds. This page walks the app from the first count to a delivered list. Everything you do here shares its audience, count and quote ids with the REST API and the MCP server, so an agent's count is your count. > **Not yet live.** MyDataHub v2 is built and tested locally and is not yet live. This page describes the v2 app. Today https://app.mydatahub.uk runs the current product, and the launch date is to confirm. ## Run a count without an account 1. Open the count screen at `mydatahub.emailmovers.com/counts`. It loads with a cached count of the whole database, server-rendered in under 2.0 seconds (target). 2. Describe who you want to reach in the brief box and press Enter. Try "marketing managers and above at manufacturing companies with 50 or more staff within 20 miles of Leeds". 3. Read the chips. Each phrase becomes a filter: a job title with its expansions, a seniority, an industry, a radius. The chips are the truth, and the count is the receipt. 4. Answer any amber question chip by pressing 1 or 2. Where a place name is ambiguous, such as Leeds as a city or a district, nothing is resolved silently. 5. Press C to copy the count link. It shows the frozen count beside the live one for 12 months. The headline number sits on a navy panel in Red Hat Mono, such as "42,318 contacts at 9,120 companies". Three toggles switch it between Total, New to you and Emailable. | View | What it counts | | --- | --- | | Total | Every matching contact at an active company | | New to you | Total, less records from your earlier orders and a connected CRM. Signed in only | | Emailable | A named email scoring 70 or more at a corporate subscriber | Without an account you get 60 counts an hour and 600 a day, and up to 6 filter chips. Free text title keywords and a radius under 5 miles need a free account. Counts under 100 and every breakdown cell are rounded to the nearest 5, and cells under 10 read "under 10". See [Counts and breakdowns](https://emailmovers.com/mydatahub/docs/counts.md). ## Read the breakdown and the sample Breakdown cards cover seniority, industry, region, employee band, email type and verification age. Click a bar to add an include chip, or Alt-click to add an exclude chip. The sample table shows 10 masked records, such as `j••••@••••.co.uk` and `0113 ••• •••1`. Each carries a badge: Verified with a date, Re-checking, Unverified, or Suppressed when it matches your list. Expand a row for "Why it matches", which lists each filter the record satisfies with its actual value. The provenance card shows the source of each field, when it was first seen and last verified, the lawful basis, the Companies House number and status, and the Article 14 notice status. Press R to shuffle for 10 more rows. No sample appears when the count is under 25. See [Samples](https://emailmovers.com/mydatahub/docs/samples.md). ## Sign up and order 1. Press O. Sign in with a magic link or a passkey. There is no password and no CAPTCHA, and 100 free credits arrive with no card. 2. Turn on New to you to connect HubSpot or Salesforce read-only. The overlap check compares salted SHA-256 hashes in memory and keeps nothing. 3. Choose a field set: Standard, Standard plus telephone or Full. Every field set costs 1 credit a record, with no telephone surcharge. 4. Read the itemised quote: contacts, suppressed records, credits needed, your balance, any top-up, VAT, the warranty and the licence. Accept the Data Licence and Sharing Agreement by click. 5. Pay by card in Stripe Checkout, which is planned and not built yet, or ask for an invoice link. Credit packs and purchases at the scale are single purchases with nothing to renew. The 12-month subscription renews unless you or we give written notice at least 30 days before the end, and we email the renewal price at least 60 days before. 6. Watch the order timeline: Queued, Building, Verifying, Ready. The file you receive has been checked in real time at delivery. Prices, ex VAT: one record costs 25p at the per-contact scale, falling to 17p from 50,000 records, with no minimum order, and a 40,000 credit pack is £1,249, the cheaper buy from 4,997 records. Every quote shows the cheapest way to buy what it needs, and credit pack credits never expire. The machine-readable plans are at `GET /v1/plans`. > **Proposed.** Outright ownership at 1.8 credits a record is a recommendation awaiting sign-off. ## Deliver the list - **Download a CSV.** The signed link lives 5 minutes, works once and is never emailed. - **Send to Google Sheets.** Part of the launch delivery set. - **Push to HubSpot or Salesforce.** A push is a record operation only and starts as a dry run: for example "4,102 to create, 0 already in your HubSpot". - **Start outreach in Deliverite.** This is the default, because HubSpot and Salesforce bar sending to bought lists from their own tools. Each order also has a signed compliance receipt, a validation report and a buyer kit with a draft legitimate interests assessment and first-email wording. See [Compliance receipts](https://emailmovers.com/mydatahub/docs/compliance-receipts.md). ## Upload a suppression list Drag a CSV of emails, domains or company numbers onto any count or the checkout. The response reads like "1,204 rows read. 1,198 valid, 6 skipped (shown). 312 matched this audience and were removed." Only hashes are stored. The first upload asks you to accept the Article 28 data processing agreement, and the named list then applies to every later count and order at no charge. See [Suppressions and objections](https://emailmovers.com/mydatahub/docs/suppressions.md). ## Keyboard shortcuts | Key | Action | | --- | --- | | `/` | Focus the brief box | | `F` | Open filters | | `S` | Save the audience | | `O` | Order | | `R` | Shuffle the sample | | `C` | Copy the count link | | `?` | Show the shortcuts | | `Cmd+K` or `Ctrl+K` | Open the command palette | ## Share a count or a quote C copies the `/counts/{id}` link, and a colleague sees every chip and can duplicate it to edit. "Get a quote" creates a human sales quote that lasts 14 days, comes as a PDF and can be paid by card or invoice link without an account. An agent's machine quote from `quote_order` lasts 24 hours and is a separate object. See [Quotes and orders](https://emailmovers.com/mydatahub/docs/quotes-and-orders.md). ## Next steps - Write code against the same counts with the [Quickstart for developers](https://emailmovers.com/mydatahub/docs/quickstart/developers.md). - Connect Claude, ChatGPT or Cursor with the [Quickstart for agents](https://emailmovers.com/mydatahub/docs/quickstart/agents.md). - Read how confirmation works in [Spending safely](https://emailmovers.com/mydatahub/docs/spending-safely.md). --- # 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(method: string, path: string, options: CallOptions = {}): Promise { const headers: Record = { 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("POST", "/counts", { body: { audience } }); console.log(`${count.contacts} contacts at ${count.companies} companies, ${count.emailable} emailable`); const quote = await call("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("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("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("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). --- # Quickstart for agents > Connect Claude, ChatGPT or Cursor to the MyDataHub MCP server at https://mydatahub.emailmovers.com/mcp, and call its 22 tools from code. Looking is free; spending needs a quote and a confirmation. Source: https://emailmovers.com/mydatahub/docs/quickstart/agents Updated: 16 October 2026 The MCP server has 22 tools, and 20 of them cost 0 credits. An agent can count, preview and quote without a key, and needs one only when a tool acts for your account. Counts, quotes and records are the same objects you see in the app and over REST. > **Not yet live.** The 22 tools described here are built and tested locally and are not yet live. Today https://mcp.mydatahub.uk serves the current product with 13 session-bound tools, which the 22 replace. The new server is at https://mydatahub.emailmovers.com/mcp. https://mcp.mydatahub.uk keeps serving today's product after launch, until the new server supports OAuth sign-in from the client, and then passes MCP through to it, so that an existing install keeps working without a reinstall. The launch date is to confirm. ## Connect a client The server URL is `https://mydatahub.emailmovers.com/mcp` on streamable HTTP, beside the REST API at `/v1` on the same host. Installs made at `https://mcp.mydatahub.uk` keep working: that address stays on today's product until the new server supports OAuth sign-in from the client, then passes MCP through to it and never redirects. At launch an assistant connects with the same `mdh_live_` API key that works on the REST API, sent as a bearer token. Signing in with OAuth 2.1 and PKCE from the client, with no key to copy, is planned for after launch. Any client that supports remote MCP servers over streamable HTTP can connect with a MyDataHub API key. A client that cannot send a key can still use the free tools, which need none. ### Claude In Claude Code, add the server from the command line with your API key as a header. ```bash claude mcp add --transport http mydatahub https://mydatahub.emailmovers.com/mcp \ --header "Authorization: Bearer $MYDATAHUB_API_KEY" ``` In Claude on the web or the desktop app, add a custom connector from the connectors settings and paste the server URL. Until OAuth sign-in from the client arrives after launch, a connector that cannot send a key uses the free tools: counts, previews and quotes. `https://mcp.mydatahub.uk` serves today's product, which does not take `mdh_` keys. > **Proposed.** A listing in the Claude directory is under consideration and not decided. Until then, add the connector by URL. ### ChatGPT Add the server as a custom connector in ChatGPT's connector settings, with the server URL. Whether custom connectors are available depends on your plan (to confirm). Until OAuth sign-in from the client arrives after launch, a connector that cannot send a key uses the free tools. When ChatGPT presents a client certificate, MyDataHub records its fingerprint on every order it places. A listing in ChatGPT apps is under consideration and not decided. ### Cursor Add the server to `.cursor/mcp.json` in your project, or the global file. The standard form takes a URL and optional headers. ```json { "mcpServers": { "mydatahub": { "url": "https://mydatahub.emailmovers.com/mcp", "headers": { "Authorization": "Bearer ${env:MYDATAHUB_API_KEY}" } } } } ``` An "Add to Cursor" deeplink on this page and a cursor.directory listing are under consideration and not decided. Check Cursor's own documentation for the config syntax of your version. ## Your first conversation Ask for what you want in plain English: "How many finance directors work at law firms in Manchester? Quote me 300 of them." The agent should walk the free steps in this order and then stop. 1. `compile_audience` turns the brief into an audience definition, a readback and any questions, such as which Manchester. 2. `count_audience` returns Total, New to you and Emailable, with the count id. 3. `preview_records` shows up to 25 masked rows with why each matches. 4. `quote_order` returns a machine quote: records after overlap and suppression, credits, GBP ex VAT, the licence and a `quote_id` valid for 24 hours. Only then does the agent call `create_order`. The server's own instructions say it plainly: "Count first, preview second, quote third, then create_order. Counting, previewing and quoting are free; one credit buys one complete UK record. create_order needs a quote_id from quote_order." ## How a spend is confirmed `create_order` needs the `quote_id` and an `idempotency_key`. It has no confirmation flag, and the input schema refuses one, because a flag set by a model is never consent. Instead one of three things confirms the spend. | How | When it applies | What the person sees | | --- | --- | --- | | Form | The host supports elicitation and a signed-in person is behind it | One checkbox that reads "Spend 5,000 credits (£1,249.00 ex VAT bought the cheapest way) for 5,000 contacts, 12 month multi-use licence" | | Approval page | The host cannot show a form, or you use a standard API key | A link to `mydatahub.emailmovers.com/quotes/{id}/approve` that returns a confirmation token | | Agent key | A human set a monthly budget and a per-order cap in the app | Nothing: the order runs inside the limits | At launch an assistant connects with an API key, so a spend is confirmed on the approval page, or by an agent key within its limits. The form needs a signed-in person behind the host, which comes with OAuth sign-in from the client after launch. With none of the three, the tool returns a `confirmation-required` problem with the `approval_url` and creates nothing. The agent keeps the `quote_id` and hands the link to you. See [Spending safely](https://emailmovers.com/mydatahub/docs/spending-safely.md). ## Agent keys An agent key is an API key with `kind` agent. A person creates it in the app with a label, the tools it may call, a monthly budget in credits, a per-order cap, an expiry and an optional webhook URL. It shows a live spend meter and can be revoked at any time. The default budget is 1,000 credits a month, £250 at the per-contact scale, until raised (to confirm). An agent key may call only the tools in its `allowed_tools`. Over a budget it gets `agent-budget-exceeded` with an `approval_url` for the key owner and a `top_up_url`. The owner is told only when the key has a `webhook_url` with an endpoint registered at that URL. See [Authentication](https://emailmovers.com/mydatahub/docs/authentication.md). ## The tools Every tool has a title, annotations and an output schema. Reads set `readOnlyHint` true. Writes set `readOnlyHint` false, `destructiveHint` false and `idempotentHint` true. Only `push_to_crm` sets `openWorldHint` true. | # | Tool | What it does | Cost | Scope | | ---: | --- | --- | --- | --- | | 1 | [`get_plans`](https://emailmovers.com/mydatahub/docs/reference/mcp/get_plans.md) | Plans and prices | Free | none | | 2 | [`get_account`](https://emailmovers.com/mydatahub/docs/reference/mcp/get_account.md) | Account and balance | Free | `account:read` | | 3 | [`list_filters`](https://emailmovers.com/mydatahub/docs/reference/mcp/list_filters.md) | Filter catalogue | Free | none | | 4 | [`search_filter_values`](https://emailmovers.com/mydatahub/docs/reference/mcp/search_filter_values.md) | Search filter values | Free | none | | 5 | [`expand_job_titles`](https://emailmovers.com/mydatahub/docs/reference/mcp/expand_job_titles.md) | Expand job titles | Free | none | | 6 | [`resolve_location`](https://emailmovers.com/mydatahub/docs/reference/mcp/resolve_location.md) | Resolve a location | Free | none | | 7 | [`compile_audience`](https://emailmovers.com/mydatahub/docs/reference/mcp/compile_audience.md) | Compile a brief | Free | none | | 8 | [`count_audience`](https://emailmovers.com/mydatahub/docs/reference/mcp/count_audience.md) | Count an audience | Free | none | | 9 | [`preview_records`](https://emailmovers.com/mydatahub/docs/reference/mcp/preview_records.md) | Preview sample records | Free | none | | 10 | [`save_audience`](https://emailmovers.com/mydatahub/docs/reference/mcp/save_audience.md) | Save an audience | Free | `audiences:write` | | 11 | [`list_audiences`](https://emailmovers.com/mydatahub/docs/reference/mcp/list_audiences.md) | List saved audiences | Free | `audiences:read` | | 12 | [`list_field_sets`](https://emailmovers.com/mydatahub/docs/reference/mcp/list_field_sets.md) | List field sets | Free | none | | 13 | [`quote_order`](https://emailmovers.com/mydatahub/docs/reference/mcp/quote_order.md) | Quote an order | Free | none | | 14 | [`create_order`](https://emailmovers.com/mydatahub/docs/reference/mcp/create_order.md) | Create an order (spends credits) | 1 credit per record | `orders:write` | | 15 | [`get_order`](https://emailmovers.com/mydatahub/docs/reference/mcp/get_order.md) | Get order status | Free | `orders:read` | | 16 | [`fetch_order_records`](https://emailmovers.com/mydatahub/docs/reference/mcp/fetch_order_records.md) | Fetch order records | Free under licence | `records:read` | | 17 | [`push_to_crm`](https://emailmovers.com/mydatahub/docs/reference/mcp/push_to_crm.md) | Push an order to a CRM | Free | `pushes:write` | | 18 | [`lookup_company`](https://emailmovers.com/mydatahub/docs/reference/mcp/lookup_company.md) | Look up a company | Free | none | | 19 | [`enrich_contact`](https://emailmovers.com/mydatahub/docs/reference/mcp/enrich_contact.md) | Enrich a contact (spends credits) | 1 credit on match | `enrich:write` | | 20 | [`check_suppression`](https://emailmovers.com/mydatahub/docs/reference/mcp/check_suppression.md) | Check suppression | Free | `suppressions:read` | | 21 | [`report_objection`](https://emailmovers.com/mydatahub/docs/reference/mcp/report_objection.md) | Report an objection | Free | `suppressions:write` | | 22 | [`get_compliance_receipt`](https://emailmovers.com/mydatahub/docs/reference/mcp/get_compliance_receipt.md) | Get a compliance receipt | Free | `orders:read` | `tools/list` returns the 22 tools in this fixed order with `ttlMs` 300000, so host prompt caches hit. A result stays under about 8,000 tokens: records pages hold at most 100 rows over MCP, and a cross-tab at most 200 cells. ## Resources and prompts Resources are documents a host can read and cache. Prompts are templates a person invokes. Every prompt walks only the free steps and says not to call `create_order` without your confirmation. | Resource | Title | Cached | Scope | | --- | --- | --- | --- | | `mydatahub://catalogue` | Filter catalogue | 24 hours, public | none | | `mydatahub://pricing` | Plans and prices | 1 hours, public | none | | `mydatahub://compliance` | Compliance and trust | 24 hours, public | none | | `mydatahub://datasets` | Datasets | 24 hours, public | none | | `mydatahub://audiences/{id}` | Saved audience | read fresh, private | `audiences:read` | | `mydatahub://orders/{id}` | Order | read fresh, private | `orders:read` | | Prompt | Arguments | What it does | | --- | --- | --- | | `build_list` | `who`, `where` (optional), `how_many` (optional) | Build a UK B2B contact list step by step: compile the brief, count, preview a masked sample and quote, stopping before any spend. | | `senior_decision_makers` | `sector`, `area` (optional) | Count senior decision makers (the sdm title bundle) in a sector and area, broken down by seniority, with a masked sample. | | `quote_from_brief` | `brief` | Turn a written brief into an audience, a count, a masked sample and a machine quote for a person to review. Nothing is ordered. | | `refresh_saved_audience` | `audience_id` | Recount a saved audience on the current dataset version and summarise what changed since its last count. | | `explain_compliance` | `use_case` | Explain how the records may be used lawfully for a given use case, from the published compliance position. | ## Call the server from code The official TypeScript SDK connects to the server and lists the tools. This needs `@modelcontextprotocol/sdk` and Node 22 or later. ```ts import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; const key = process.env.MYDATAHUB_API_KEY; const transport = new StreamableHTTPClientTransport(new URL("https://mydatahub.emailmovers.com/mcp"), { requestInit: key ? { headers: { Authorization: `Bearer ${key}` } } : {}, }); const client = new Client({ name: "example-agent", version: "1.0.0" }); await client.connect(transport); const { tools } = await client.listTools(); console.log(tools.map((tool) => tool.name).join(", ")); const counted = await client.callTool({ name: "count_audience", arguments: { audience: { schema: "mydatahub.audience/1", where: { all: [ { field: "company.sic_2007", op: "in", values: ["C"] }, { field: "company.employee_band", op: "gte", value: "50-99" }, ], }, }, }, }); console.log(counted.structuredContent); await client.close(); ``` `structuredContent` matches the tool's output schema, and the first text block is a one-line summary that leads with the number. A failed call returns `isError` true, with the sentence a person acts on followed by the whole problem as JSON. See [Errors](https://emailmovers.com/mydatahub/docs/errors.md). ## Protocol revisions The server speaks the [2026-07-28 revision](https://modelcontextprotocol.io/specification/2026-07-28/changelog) natively and keeps 2025-11-25 and 2025-06-18 clients working. Legacy `initialize` is accepted, `Mcp-Session-Id` is ignored, `GET` and `DELETE` return 405, and the `Origin` header is validated. `server/discover` advertises the supported versions with `ttlMs` 3600000 and a public cache scope. ## Next steps - Read [Spending safely](https://emailmovers.com/mydatahub/docs/spending-safely.md) before you let an agent order. - Browse every tool in the [MCP tool reference](https://emailmovers.com/mydatahub/docs/reference/mcp.md). - Moving from today's tools? Use the [migration guide](https://emailmovers.com/mydatahub/docs/migrate.md). --- # The audience definition > Every count starts from one JSON document in the namespace mydatahub.audience/1: filters in groups, with fields, operators, title expansion, geography and defaults. Source: https://emailmovers.com/mydatahub/docs/audience Updated: 17 October 2026 An audience is one JSON document, and the app's chips, a brief compiled by an agent and a saved audience (`aud_`) all produce the same one. It has 6 members, 32 fields in 6 groups and 11 operators, and the same document gives the same count over REST, MCP and in the app. The JSON Schema is at [/reference/audience.schema.json](https://emailmovers.com/mydatahub/docs/reference/audience.schema.json). > **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. ## A worked example This audience is "marketing managers and above at manufacturing companies with 50 or more staff within 20 miles of Leeds, excluding my suppression list". ```json { "schema": "mydatahub.audience/1", "dataset": "uk_contacts", "dataset_version": "2026-W39", "where": { "all": [ { "field": "contact.title", "op": "matches", "terms": ["marketing manager"], "mode": "contains", "expand": ["plural_singular", "department_synonyms", "ampersand_and", "abbreviations"] }, { "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" }, "radius_miles": 20 }, { "not": { "field": "email.address_sha256", "op": "in_list", "list_id": "sup_01JB4H7Q2V7XN3M9R6T1Y5W8ZA" } } ] }, "email_policy": { "min_deliverability_score": 70, "allow_catch_all": false }, "dedupe": "none" } ``` Read it top to bottom. The `all` group is an AND of six nodes. 1. A job title with its expansions. 2. The seniority ranks for Manager and above. 3. The SIC section for manufacturing. 4. 50 or more employees. 5. A 20 mile radius around Leeds. 6. A `not` that removes your suppression list. Send it to `POST /v1/counts` as `audience`, or save it with `POST /v1/audiences`. Two rules apply when you send this example as written. Leeds is ambiguous, so the engine refuses it with `422 validation-failed` and the candidate ONS codes in `did_you_mean`. Pin one with `code`, such as `{ "place": "Leeds", "code": "E08000035" }` for the metropolitan district. Free text job titles, `contact.title` with `matches`, also need a free account. An anonymous request that uses one gets `401 account-required`. ## Members An audience has these top-level members. Only `schema` is required, and parsing never fills a default. | Member | Type | Required | Description | | --- | --- | --- | --- | | `schema` | `mydatahub.audience/1` | yes | The definition's namespace and version. Older versions are upgraded by a pure function. | | `dataset` | enum: `uk_contacts`, `ie_contacts`, `global_contacts` | no | The dataset an audience draws from. uk_contacts is live; ie_contacts and global_contacts are listed but not yet served. | | `dataset_version` | string | no | The weekly build to count against, such as 2026-W39. Left out, the current version is filled in and returned. | | `where` | `WhereNode` | no | The filters. Left out, the audience is the whole dataset after the defaults. See [WhereNode](https://emailmovers.com/mydatahub/docs/audience.md#groups-and-filters). | | `email_policy` | `EmailPolicy` | no | Email quality floor. Defaults: min_deliverability_score 70, allow_catch_all false. | | `email_policy.min_deliverability_score` | integer (0 to 100) | no | Lowest deliverability score counted, 0 to 100. Default 70. | | `email_policy.allow_catch_all` | boolean | no | Whether catch-all addresses count. Default false. | | `dedupe` | enum: `none`, `one_per_company`, `one_per_site` | no | none keeps every matching contact, one_per_company keeps one contact per company, one_per_site keeps one per site. Default none. | `level` and `breakdown_by` belong to the count request, not the audience. `dedupe` takes `none`, `one_per_company` or `one_per_site`. ## Groups and filters A node in `where` is a group or a filter. A group is `all` (AND), `any` (OR) or `not` (negation), and groups nest. A tree may nest at most 32 groups deep and hold at most 1,000 nodes, so a malformed body gets a 422 problem, never a crash. A filter has a `field`, an `op` and the operands that op takes. | Operator | Applies to | Meaning | | --- | --- | --- | | `all` | groups | AND: every node inside must match. Nests to any depth. | | `any` | groups | OR: at least one node inside must match. Nests to any depth. | | `not` | groups | Negation: the single node inside must not match. | | `in` | coded fields | One of the listed codes. | | `between` | banded and ranked fields | Inclusive range from one value to another over an ordered code list or a number. | | `gte` | banded and ranked fields | The value and everything after it in the order (for bands, larger bands). | | `lte` | banded and ranked fields | The value and everything before it in the order. For seniority, rank 1 is the most senior, so and above is lte. | | `matches` | contact.title | Keyword match with expansion sets and a mode: contains (the default), starts_with or exact. | | `within_radius` | site.location | Within radius_miles (1 to 100) of a resolved centre: a place, a postcode or a latitude and longitude. | | `in_list` | hashed email, domain, phone, company number and title term | Membership of an uploaded suppression list, named by its sup_ id. | | `since` | date fields and signal.type | Within the last N days. | Each operator takes its own operands. | Operator | Operands | | --- | --- | | `in` | `values`, a list of codes | | `gte`, `lte` | `value`, a code or number | | `between` | `from` and `to`, inclusive | | `matches` | `terms`, `mode`, `expand` and `exclude_terms` | | `within_radius` | `centre` and `radius_miles` | | `in_list` | `list_id`, a `sup_` id | | `since` | `days`, 1 to 3,650, and `values` for `signal.type` | A boolean field such as `email.exists` uses `in` with `[true]` or `[false]`, because there is no equality operator. ## Fields Fields are grouped by entity: `company.`, `site.`, `contact.`, `email.`, `phone.` and `signal.`. The catalogue is public. `GET /v1/catalogue/filters` lists every field with its type, operators and value count, and `GET /v1/catalogue/filters/{dimension}/values?q=` searches the values of one field with a contact and company count for each. The `list_filters` and `search_filter_values` tools do the same. | Field | Label | Operators | Breakdown | Values | | --- | --- | --- | --- | --- | | `company.sic_2007` | Industry (SIC 2007) | `in` | yes | a SIC 2007 section letter A to U or a code of 2 to 5 digits, such as C, 62 or 62012 | | `company.business_class` | Business class | `in` | yes | a business class code from the catalogue, such as E2935 (Accountants) | | `company.employee_band` | Employees | `in`, `between`, `gte`, `lte` | yes | 13 codes, 1-5 to 10000+ (ordered) | | `company.turnover_band` | Turnover | `in`, `between`, `gte`, `lte` | yes | 17 codes, 1-100k to 1B+ (ordered) | | `company.fleet_band` | Fleet size | `in`, `between`, `gte`, `lte` | yes | 7 codes, 1-2 to 100+ (ordered) | | `company.type` | Company type | `in` | yes | a company type code in lower case with underscores, such as ltd or charitable_incorporated_organisation | | `company.status` | Company status | `in` | yes | a company status code in lower case with underscores, such as active or dissolved | | `company.subscriber_class` | Subscriber class | `in` | yes | 9 codes, ltd to unknown | | `company.companies_house_number` | Companies House number | `in`, `in_list` | no | an eight character Companies House number in upper case, such as 05046417 or SC123456 | | `site.region` | Region | `in` | yes | 13 codes, north_east to crown_dependencies | | `site.county` | County | `in` | yes | a county code from the catalogue, such as west_yorkshire | | `site.town` | Town | `in` | yes | a town code from the catalogue, such as leeds | | `site.postcode` | Postcode | `in` | yes | a postcode area, district, sector or unit, such as LS, LS1, LS1 4 or LS1 4AP | | `site.location` | Radius | `within_radius` | no | a place, postcode or latitude and longitude | | `contact.title` | Job title | `matches`, `in_list` | no | free text, up to 200 characters | | `contact.title_family` | Job role | `in` | yes | a canonical job title code from the catalogue, such as marketing_manager | | `contact.job_function` | Job function | `in` | yes | a job function code from the catalogue, such as marketing or finance | | `contact.job_level_rank` | Seniority | `in`, `between`, `gte`, `lte` | yes | 13 codes, 1 to 13 (ordered) | | `contact.premium` | Premium | `in` | no | true or false | | `contact.last_validated_at` | Validation recency | `since` | no | days (1 to 3,650) | | `contact.company_contact_rank` | Contacts per company | `lte` | no | whole number 1 to 1000 | | `email.exists` | Has email | `in` | yes | true or false | | `email.type` | Email type | `in` | yes | 3 codes, named to generic_domain | | `email.deliverability_score` | Deliverability score | `gte`, `lte`, `between` | no | whole number 0 to 100 | | `email.last_verified_at` | Verification recency | `since` | no | days (1 to 3,650) | | `email.address_sha256` | Email suppression | `in_list` | no | SHA-256 hash in a suppression list | | `email.domain_sha256` | Domain suppression | `in_list` | no | SHA-256 hash in a suppression list | | `phone.exists` | Has phone | `in` | yes | true or false | | `phone.tps_ctps_status` | TPS and CTPS | `in` | yes | 5 codes, clear to not_screened | | `phone.e164_sha256` | Phone suppression | `in_list` | no | SHA-256 hash in a suppression list | | `signal.type` | Company signals | `since` | no | 7 codes, company.incorporated to company.confirmation_statement_overdue | | `signal.observed_at` | Any recent signal | `since` | no | days (1 to 3,650) | A field marked for breakdown can also appear in `breakdown_by`. See [Counts and breakdowns](https://emailmovers.com/mydatahub/docs/counts.md). ## Job titles `contact.title` with `matches` is a keyword match. Title expansion is data, not code: a dictionary of normalised titles and a synonyms table keyed by expansion set. The server returns the expanded list with a count per term, from `POST /v1/job-titles/expand` and with every count, so nothing is hidden behind a toggle. The `mode` is `contains` by default, `starts_with` narrows it, and `exact` takes a verbatim list with no expansion. The `expand` member names the sets to apply. Left out, the first four apply. An empty list applies none, and `permutations` is opt in because it broadens most. | Set | What it matches | Examples | | --- | --- | --- | | `plural_singular` | Matches the singular and plural of each word. | manager, managers | | `department_synonyms` | Matches the other names for a department. | HR, Human Resources, People; IT, Information Technology | | `ampersand_and` | Treats an ampersand (&) and the word "and" as the same. | Sales & Marketing, Sales and Marketing | | `abbreviations` | Matches an abbreviation and its full form. | CEO, Chief Executive Officer; MD, Managing Director | | `permutations` | Matches the same title with its words in another order. | Marketing Director, Director of Marketing | Remove a term by adding it to `exclude_terms`. The exclusion is written into the definition, so the edited count stays reproducible when the title dictionary changes. See [Versions and reproducibility](https://emailmovers.com/mydatahub/docs/versions.md). ## Seniority and bundles `contact.job_level_rank` runs from 1 (Owner) to 13 (Volunteer). Rank 1 is the most senior, so "and above" is `lte`. A contact with no known level has rank 0, Level not known: only `in` with 0 matches it, so no range and no "and above" includes it, and its records and samples show no level. | Rank | Level | Slug | | ---: | --- | --- | | 1 | Owner | `owner` | | 2 | Partner | `partner` | | 3 | CxO | `cxo` | | 4 | Chair | `chair` | | 5 | VP | `vp` | | 6 | Director | `director` | | 7 | Company secretary | `company_secretary` | | 8 | Senior manager | `senior_manager` | | 9 | Manager | `manager` | | 10 | Senior staff | `senior_staff` | | 11 | Junior staff | `junior_staff` | | 12 | Trainee | `trainee` | | 13 | Volunteer | `volunteer` | | 0 | Level not known | `not_known` | A bundle is a named shortcut written as `{ "bundle": "sdm" }`. The server swaps it for its fragment before the count id is hashed, so the effective definition holds the fragment and not the name. | Bundle | Resolves to | Status | | --- | --- | --- | | `sdm` | Owner to Director: job level ranks 1 to 6. The 360 SDM bundle. | to confirm | | `manager_and_above` | Job level ranks 1 to 6, 8 and 9. Excludes Company secretary (rank 7), matching the 1,277,443 count of 26 September 2026. | confirmed | | `owner_managed` | Owners and partners (ranks 1 and 2) at companies with fewer than 50 employees. Proposed. | proposed | ## Geography Every site carries ONS codes for region, county, local authority, ward, LSOA and ITL levels, plus latitude, longitude and an H3 cell, from the quarterly ONS Postcode Directory under the Open Government Licence v3.0. - **Region, county and town.** Use `in` with codes from the catalogue. The Crown Dependencies are one region value. - **Postcode.** Use `in` with a hierarchical code: area `LS`, district `LS1`, sector `LS1 4` or unit `LS1 4AP`. A sector needs its space, because `LS14` is the district LS14. There is no `starts_with` operator on postcodes. - **Radius.** `within_radius` takes a `centre` and `radius_miles` from 1 to 100. The centre is exactly one of `{ "place": "Leeds" }`, `{ "postcode": "M1 1AE" }` or `{ "lat": 53.8, "lon": -1.55 }`. A radius under 5 miles needs a free account. An ambiguous place name such as Leeds comes back from `POST /v1/locations/resolve` as candidates, never as a silent choice. Pin one with `{ "place": "Leeds", "code": "..." }` using the ONS code from the candidate. London (33 boroughs including the City of London) and Yorkshire (North, South and West Yorkshire and the East Riding) remain as named geography sets. ## Premium records `contact.premium` keeps premium records: a contact at an active company with a LinkedIn profile, at launch in the premium export of a stated date (the dataset version's as-of date), and later validated within the last 12 months from the validations feed. Use `in` with `[true]` for premium records only or `[false]` for the rest. ```json { "field": "contact.premium", "op": "in", "values": [true] } ``` Two more fields come from the same data. `contact.last_validated_at` with `since` keeps records validated in the last N days. `contact.company_contact_rank` with `lte` keeps at most that many contacts per company, most senior first and Level not known last; the rank is set across the whole dataset version, so other filters can leave a company with fewer. The server never applies either one for you. Premium is the default view in the app and in a compiled brief, never in a count: an audience you send is counted exactly as written. Every count shows the premium figure beside all records, so turning premium on or off never hides the other number. See [Counts and breakdowns](https://emailmovers.com/mydatahub/docs/counts.md#premium-and-all-records). A dataset version without premium data cannot answer these fields. A filter on one gets `422 validation-failed` with the reason, and `GET /v1/catalogue/filters` leaves it out, so a count is never 0 for want of data. ## Suppressions inside an audience `in_list` removes the records on one of your lists. Each list has a kind that must match the field: hashed email on `email.address_sha256`, domain on `email.domain_sha256`, phone on `phone.e164_sha256`, company number on `company.companies_house_number` and title term on `contact.title`. Every count reports how many records each list removed in `compliance_summary.suppressed_removed`. See [Suppressions and objections](https://emailmovers.com/mydatahub/docs/suppressions.md). ## Defaults the server fills You do not have to ask for these. The server applies each one, writes it into the effective definition it returns, and shows it as a toggle with the number it removed. | Default | What it does | Applies to | | --- | --- | --- | | Active companies | `company.status` is active | Every view | | Named emails | `email.type` is named, not `info@` | Emailable | | Corporate subscribers | Sole traders and ordinary partnerships are left out of email audiences | Emailable | | No registered numbers | Numbers a screen found TPS or CTPS registered are excluded when a phone is requested; numbers not screened stay in | Every view | | Your suppressions | Every list on your account is applied | Every view | The server also fills the current `dataset_version` and the email policy: a deliverability score of at least 70, with catch-all addresses excluded. Older schema versions are upgraded by a pure function, so an audience saved in 2026 still compiles in 2028. ## More examples New incorporations in London, at senior decision makers with an email, using a bundle and a signal. ```json { "schema": "mydatahub.audience/1", "dataset": "uk_contacts", "where": { "all": [ { "field": "signal.type", "op": "since", "values": ["company.incorporated"], "days": 90 }, { "field": "site.region", "op": "in", "values": ["london"] }, { "bundle": "sdm" }, { "field": "email.exists", "op": "in", "values": [true] } ] } } ``` Marketing at director level and above, or sales directors other than interim ones, excluding sole traders, partnerships and dissolved companies, with `any`, `all` and `not` nested. ```json { "schema": "mydatahub.audience/1", "dataset": "uk_contacts", "where": { "all": [ { "any": [ { "all": [ { "field": "contact.title", "op": "matches", "terms": ["marketing"] }, { "field": "contact.job_level_rank", "op": "lte", "value": 6 } ] }, { "all": [ { "field": "contact.title", "op": "matches", "terms": ["sales director"] }, { "not": { "field": "contact.title", "op": "matches", "terms": ["Sales Director (Interim)"], "mode": "exact" } } ] } ] }, { "not": { "any": [ { "field": "company.subscriber_class", "op": "in", "values": ["sole_trader", "ordinary_partnership"] }, { "field": "company.status", "op": "in", "values": ["dissolved", "liquidation"] } ] } } ] } } ``` Managers and above at engineering companies in the Midlands, one contact per company. ```json { "schema": "mydatahub.audience/1", "dataset": "uk_contacts", "dataset_version": "2026-W39", "where": { "all": [ { "bundle": "manager_and_above" }, { "field": "company.sic_2007", "op": "in", "values": ["25", "28", "29"] }, { "field": "site.region", "op": "in", "values": ["east_midlands", "west_midlands"] }, { "field": "company.turnover_band", "op": "gte", "value": "2.1M-5M" } ] }, "dedupe": "one_per_company" } ``` ## Build one from a brief Send plain English to `POST /v1/audiences/compile` or call `compile_audience`. You get the audience, a one-line readback, a chip for each phrase with the words it came from, and any questions that need an answer, such as which Manchester. Nothing ambiguous is resolved silently. ```json { "brief": "300 finance directors at law firms in Manchester" } ``` The chips are the truth and the count is the receipt. Run the compiled audience through `POST /v1/counts` to see its size. ## Validate a definition Validation errors are RFC 9457 problems. Each entry in `errors` has a JSON pointer such as `/audience/where/all/0/field`, a message and, where there is one, `did_you_mean`. A field, operator, mode or code that is not recognised comes back with its closest valid values. See [Errors](https://emailmovers.com/mydatahub/docs/errors.md). For a local check, validate against the published JSON Schema, whose `$id` is `https://mydatahub.emailmovers.com/schemas/audience/1.json`. The schema holds which operator each field takes and the per-field value rules. One rule it cannot express is that a `between` range must be in order, which the server checks. ## Next steps - Run an audience through [Counts and breakdowns](https://emailmovers.com/mydatahub/docs/counts.md). - Reproduce a count later with [Versions and reproducibility](https://emailmovers.com/mydatahub/docs/versions.md). - See the field values at [REST reference: List filter fields](https://emailmovers.com/mydatahub/docs/reference/rest/list-filters.md). --- # Counts and breakdowns > Count an audience for free and get Total, New to you and Emailable, the premium figure beside all records, up to three breakdowns, quality signals and a compliance summary. Small numbers are rounded for anonymous callers. Source: https://emailmovers.com/mydatahub/docs/counts Updated: 17 October 2026 A count costs 0 credits, needs no account and returns in one request: contacts and companies together, three headline views, the premium figure beside all records, up to three breakdowns, and a content-addressed id that replays for 12 months. `POST /v1/counts` and `count_audience` take an audience, a `level` and up to 3 `breakdown_by` fields. The same audience gives the same number in the app, over REST and over MCP. > **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. ## Run a count Send an [audience definition](https://emailmovers.com/mydatahub/docs/audience.md) inline, or the `aud_` id of a saved one. ```json { "audience": { "schema": "mydatahub.audience/1", "dataset": "uk_contacts", "where": { "field": "company.sic_2007", "op": "in", "values": ["C"] } }, "level": "contact", "breakdown_by": ["contact.job_level_rank"] } ``` `level` is `contact` (the default) or `company`. Contacts and companies come back together either way, so "12,840 contacts at 3,115 companies" is one call. See [REST reference: Count an audience](https://emailmovers.com/mydatahub/docs/reference/rest/create-count.md) for every member. ## Three views Every count carries the same three headline views, and `contacts` is the Total. | View | Member | What it counts | | --- | --- | --- | | Total | `contacts`, `companies` | Every matching contact at an active company, with or without an email | | New to you | `new_to_you` | Total, less records from your earlier orders and a connected CRM. `null` when signed out | | Emailable | `emailable` | A named email scoring 70 or more at a corporate subscriber | `views` repeats all three with contacts and companies each. It is the number to use before ordering, because records you already hold are never charged. ## Premium and all records Every count carries the premium figure beside the full one, so no number changes meaning without saying so. | Member | What it counts | | --- | --- | | `premium` | The contacts of `all_records` that are premium records. `null` when the dataset version carries no premium data, or withheld when the premium records are at a single company and the caller has no account | | `all_records` | The full figure: the audience with its top-level premium filters taken out. A premium filter inside `any` or `not` stays. The same as `contacts` when it has none | | `premium_note` | What premium means on this dataset version, or why `premium` is `null` | `views.premium` and `views.all_records` give contacts and companies for each. Both are rounded for anonymous and trial callers exactly as every other figure is. Where the premium records are at a single company, an anonymous caller gets `premium` as `null`, with the reason in `premium_note`, and the rest of the count as usual. A premium record is at an active company and has a LinkedIn profile. At launch it is a record in the premium export of a stated date, the dataset version's as-of date; later it will be a record validated within the last 12 months, from the validations feed. Filter on it with `contact.premium`, described in [The audience definition](https://emailmovers.com/mydatahub/docs/audience.md#premium-records). A count runs your audience exactly as you send it, and the server never adds a premium filter. The app opens a new count with the premium chip on, and `POST /v1/audiences/compile` and `compile_audience` add `contact.premium` by default when the dataset version carries premium data, and say so in the readback. Remove the chip, or say "all records" in the brief, to count every record. On a dataset version without premium data, `premium` is `null`, `premium_note` says so, and a `contact.premium` filter gets `422 validation-failed` with the reason. It never counts 0 for want of data. ## Defaults and toggles The server applies four defaults and reports each one in `defaults`, with whether it was applied, the views it shapes and how many records it removed. | `defaults` id | Removes | Shapes | | --- | --- | --- | | `active_companies` | Dissolved and insolvent companies | Every view | | `named_emails` | Generic addresses such as `info@` | Emailable | | `corporate_subscribers` | Sole traders and ordinary partnerships, which are individual subscribers under PECR | Emailable | | `no_registered_phones` | Numbers a screen found TPS or CTPS registered, when a phone is requested; numbers not screened stay in | Every view | The numbers behind the toggles let a person or an agent see what a default costs before turning it off. Premium is not one of these defaults: it is a filter in the audience, shown with the figure beside it (see [Premium and all records](#premium-and-all-records)). Your own suppression lists are applied on top and reported per list in `compliance_summary.suppressed_removed`. ## Breakdowns Up to 3 fields in `breakdown_by` run as one grouped query. Each field returns its top 50 cells and an `other` bucket. Two or three fields also return a `crosstab`, capped at 5,000 cells and 200 KB. ```json { "by": "contact.job_level_rank", "cells": [ { "value": "director", "contacts": 4120 } ] } ``` Hierarchical fields break down at one level: SIC by section and postcode by district. A seniority breakdown shows contacts with no known level last, as `not_known` labelled Level not known, with `filter_value` 0. A cell count is a whole number, the text `under 10`, or `suppressed` where a cell of 10 or more is hidden so that totals cannot be differenced. Over MCP a cross-tab holds at most 200 cells, with `truncated` true when the cap cut it. Ask for fewer dimensions to see the rest. ## Disclosure control Small numbers can identify people, so what you see depends on who you are. This is the suppress-and-round practice used for UK statistical outputs, as in [OpenSAFELY disclosure control](https://docs.opensafely.org/outputs/sdc/). | Caller | Counts | Breakdown cells | | --- | --- | --- | | Anonymous | Counts under 100 rounded to the nearest 5 | Every cell rounded to the nearest 5, under 10 shown as `under 10` | | Free or trial account | The same as anonymous | The same as anonymous | | Paid account | Exact | Exact, with under 10 shown as `under 10` | Every response that carries counts includes a `disclosure` object with its `level` (`exact` or `rounded`), `rounded_to` and `secondary_suppression`, so you can tell which figures you are reading. Single-company counts need an account and are logged. A preview is withheld when the count is under 25. See [Samples](https://emailmovers.com/mydatahub/docs/samples.md). ## Quality signals Each count shows how good the records are, with the date of measurement, so "verified" is never shown without a date. | Member | What it measures | | --- | --- | | `quality.email_coverage_pct` | Records with a named email as a share of the count | | `quality.verified_90d_pct` | Share of emails with a provider result of ok in the last 90 days | | `quality.deliverability` | Distribution: good is 70 to 100, risky is 40 to 69, poor is under 40 | | `quality.avg_days_since_verification` | Mean age of the latest verification result | | `quality.phone_coverage_pct`, `quality.tps_ctps_screened_at` | Phone coverage and the version's TPS and CTPS screen date, when a phone is requested | ## Compliance summary Every count returns `compliance_summary` beside the number, so you can reason about lawfulness before you spend. It holds the lawful basis (UK GDPR Article 6(1)(f) legitimate interests) and counts of corporate subscribers, individual subscribers excluded, TPS and CTPS numbers excluded, Article 14 notified and pending, and records removed per suppression list. See [Compliance receipts](https://emailmovers.com/mydatahub/docs/compliance-receipts.md). ## Price estimate `estimate` gives the credits an order of the whole count would need and the price in GBP ex VAT two ways: `per_contact_scale`, every credit at the scale, and `cheapest`, the cheapest real purchase at the scale or with credit packs. It is an estimate only. A [machine quote](https://emailmovers.com/mydatahub/docs/quotes-and-orders.md) is the price you pay. ## Replay a count `GET /v1/counts/{id}` returns the effective definition, the compiled plan (expanded terms, resolved centres, SIC codes behind a business class, suppression checksums), the pinned `dataset_version` and `engine_rules_version`, all three views with premium and all records, and the number on the current dataset. The `ETag` equals the id, so `If-None-Match` answers 304 without running the engine. A count expires 12 months after it ran. See [Versions and reproducibility](https://emailmovers.com/mydatahub/docs/versions.md). An objection or erasure request reaches every count the same day through an overlay. A pinned re-run reports `removed_since` beside the original number. ## Next steps - Look at who is in the count with [Samples](https://emailmovers.com/mydatahub/docs/samples.md). - Price it with [Quotes and orders](https://emailmovers.com/mydatahub/docs/quotes-and-orders.md). - Save and share it with `POST /v1/audiences`, then count by `aud_` id. --- # Samples > Preview up to 25 masked records for free, with why each one matches and where each field came from. The same audience always returns the same rows. Source: https://emailmovers.com/mydatahub/docs/samples Updated: 4 October 2026 A sample costs 0 credits and shows up to 25 masked records before you spend anything: 10 rows in the app and up to 25 through `POST /v1/previews` or `preview_records`. Each row says why it matches and where each field came from, which is the evidence the ICO asks buyers of broker data to check. Contact details stay masked until you order. > **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. ## Request a sample Send exactly one of `audience` or `count_id`. ```json { "count_id": "cnt_7K2PQ4MAXN3R6TBY5WDZ", "limit": 10 } ``` | Member | Meaning | | --- | --- | | `audience` or `count_id` | The audience to sample, inline or by `aud_` id, or a count you already ran. Exactly one | | `limit` | Rows to return, 1 to 25. Default 10 | | `shuffle` | Draw a new seed. Default false, so a repeat returns the same rows | Rows come from a seed fixed per audience, so repeating a request returns the same rows. A shuffle draws a new seed, counts against your rate budget and is logged. See [Rate limits](https://emailmovers.com/mydatahub/docs/rate-limits.md). ## What a row holds | Member | What it shows | | --- | --- | | `id` | A session-scoped `prv_` id. It means nothing outside the session | | `title`, `job_level`, `job_function` | The role | | `company`, `site` | Company name, Companies House number and status, employee band, SIC codes, town, postcode district and region | | `email_masked`, `phone_masked` | The first character of the local part and the top-level domain, such as `j••••@••••.co.uk`, and a phone with its middle digits hidden, such as `0113 ••• •••1` | | `badges` | Verified with a date, Re-checking, Unverified, or Suppressed when the row matches one of your lists | | `why_it_matches` | Each filter the record satisfies, with a JSON pointer, the field, the actual value and a sentence | | `provenance` | The source of each field, when it was first seen and last verified, the verification method, the Article 14 notice status, the TPS and CTPS screen date (none when not screened) and the lawful basis | | `compliance` | The full compliance object that every record carries | A "why it matches" entry reads like "Marketing Director matched marketing manager through expansion" or "Employees 120, inside 50 or more". Masking runs on the server, so an unmasked value never leaves it. ## Rules that protect people - **No sample under 25.** When the count is under 25 the response is a 200 with no rows and a `withheld` reason, because looking is free and a small count must not identify anyone. - **Rare titles are withheld.** A row whose title is held by fewer than 10 people is left out. - **Sample files are watermarked.** A sample downloaded as CSV carries your account id. - **Verified always has a date.** A badge never says verified without one. ## Why samples exist A count says how many. A sample says whether they are the right people, with the evidence to judge lawful use. The next step is a [machine quote](https://emailmovers.com/mydatahub/docs/quotes-and-orders.md), which prices the count and tells you what an order would remove as duplicates or suppressions. ## Next steps - Read what each count measures in [Counts and breakdowns](https://emailmovers.com/mydatahub/docs/counts.md). - See every member in [REST reference: Preview sample records](https://emailmovers.com/mydatahub/docs/reference/rest/create-preview.md). - Check records you already hold with [Suppressions and objections](https://emailmovers.com/mydatahub/docs/suppressions.md). --- # Quotes and orders > A machine quote prices a count for 24 hours at 0 credits. An order spends credits against that quote and delivers records under a 12 month licence. Source: https://emailmovers.com/mydatahub/docs/quotes-and-orders Updated: 4 October 2026 You pay once, for a complete UK record, at 1 credit each. A machine quote tells you the price before you commit, costs nothing and lasts 24 hours, and an order spends credits against it. Quoting and ordering use the same objects in the app, over REST and over MCP. > **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. ## Two kinds of quote Whenever the word quote appears, it is one of two objects. | | Machine quote | Human sales quote | | --- | --- | --- | | Made by | `POST /v1/quotes`, `quote_order` | The app, or an account manager | | Lasts | 24 hours | 14 days | | Form | JSON, signed ES256, bound to a dataset version | A PDF, payable by card or invoice link | | Used for | Ordering by API, agent or app | Buying without an account, or sharing with a colleague | | Id | `qte_` with `kind` machine | `qte_` with `kind` sales | Both share the `qte_` prefix. The rest of this page is about the machine quote. ## Make a quote Count first, then quote the count. A quote is free and needs no account. ```json { "count_id": "cnt_7K2PQ4MAXN3R6TBY5WDZ", "field_set": "standard", "limit": 5000, "dedupe_against": "previous_orders", "licence": "multi_use_12m" } ``` | Member | Meaning | | --- | --- | | `count_id` | A count from `POST /v1/counts`. Either case is accepted | | `field_set` | A field set id from `GET /v1/field-sets`. Every field set costs 1 credit a record | | `limit` | Records to buy. Left out, the whole count | | `dedupe_against` | `none`, `previous_orders`, `crm` or `previous_orders_and_crm`. The CRM options need a `connection_id` | | `licence` | `multi_use_12m`, or `outright` (proposed, 1.8 credits a record) | Records you already hold under licence are always left out of a quote, whatever `dedupe_against` says. The value only adds the CRM overlap check. ## Read a quote This response is abridged. ```json { "id": "qte_01JB4M2R6T1Y5W8ZAK8Q2V7XN3", "kind": "machine", "records": 5000, "removed": { "already_held": 312, "suppressed": 41 }, "credits_required": 5000, "credits_available": 6200, "gbp_ex_vat": 1495, "licence": { "type": "multi_use_12m", "reuse_until": "2027-09-26", "resale_prohibited": true }, "warranty": { "policy": "replace_over_threshold_hard_bounce" }, "expires_at": "2026-09-27T10:14:00Z", "blockers": [] } ``` - `records` is what you would receive, after overlap and suppression. `removed` records are never charged. - `credits_available` and `balance_after` need an account. They are `null` when signed out, and `balance_after` is negative when you need a top-up. - `blockers` lists why the quote cannot be ordered yet: `insufficient_credits`, `account_required`, `agent_budget_exceeded`, `dataset_unavailable` or `count_expired`. - `signature` is an ES256 JWS over the quote's price, so one agent can hand a quote to another and both can check it against the key at `jwks_url`. A quote made without an account is taken by the first organisation that orders it, and is then priced again on that organisation's own records and lists. The order charges the smaller of the quote and what that organisation can still be sold. ## Credits One credit buys one complete UK record: a named email, a telephone number where held, a postal address and company fields including the Companies House number and SIC, delivered once. The price is the same in the app, the API and MCP. There are no seats, no telephone surcharge and no separate export or API credits. | Action | Credits | Per | Status | | --- | ---: | --- | --- | | Count, breakdown, preview, machine quote, catalogue, title expansion | 0 | call | confirmed | | Full UK contact record delivered, any channel or field set | 1 | record | confirmed | | Company-only record (no named contact) | 0.1 | record | proposed | | Contact enrichment, match found (0 if already licensed) | 1 | match | confirmed | | Company lookup, no contact details | 0 | call | to confirm | | Outright ownership | 1.8 | record | proposed | Never charged: a failed lookup, a duplicate, a suppressed record, a record you already hold, a re-download of a record still under licence, and a verification re-check. Credits are whole numbers on every charge in the current build. The fractional rates are proposed and not yet in force. The prices, ex VAT, are below (price model A, from 13 October 2026). - One credit buys one record at the per-contact scale: 25p a record under 10,000, 22p from 10,000, 19.5p from 25,000 and 17p from 50,000. The size of a purchase picks one band for all of it, and there is no minimum order. - A 40,000 credit pack is £1,249, and credit pack credits never expire. It is cheaper than the scale from 4,997 records. - New customers can subscribe at £500 a month on a 12-month contract for 2,000 records a month, used or lost each month. - Pay as you go (£299 a month) and the 12-month plan (£249 a month) stay for existing customers only. - The free plan is 100 credits with no card. A machine quote's `gbp_ex_vat` is the order bought the cheapest way, and its `purchase` lists what you need to buy now, cheapest first: the credits your balance does not cover, at the scale or with credit packs. Credits and credit packs are not refundable once bought, except where the law requires, and no plan has a pro rata refund. Existing customers keep what their signed agreement says until it renews. `GET /v1/plans` returns all of it as data. ## Create an order `POST /v1/orders` spends credits against a live `quote_id`. It needs an `Idempotency-Key` header and a confirmation, which [Spending safely](https://emailmovers.com/mydatahub/docs/spending-safely.md) explains. Without a `quote_id` the answer is `422 quote-required` and nothing is charged. An account made by self-serve sign-up confirms its business name and that it is a UK business before its first order or enrichment, free or paid. Until a person who owns it does, in the app's Settings or with [`PATCH /v1/account`](https://emailmovers.com/mydatahub/docs/reference/rest/update-account.md), the answer is [`403 business-details-required`](https://emailmovers.com/mydatahub/docs/errors/business-details-required.md) and nothing is charged. ```json { "quote_id": "qte_01JB4M2R6T1Y5W8ZAK8Q2V7XN3", "name": "Leeds marketing Q4", "delivery": ["api", "csv"] } ``` The order answers `202` with the charge already made. | Member | Meaning | | --- | --- | | `status` | `queued`, `building`, `verifying`, `ready`, `failed` or `cancelled` | | `credits_charged`, `credits_remaining`, `cost_gbp` | What the order cost, the balance right after it and the GBP charge ex VAT | | `licence_expires_at`, `licence` | When the licence ends and its terms | | `confirmed_by` | Who approved the spend and from which client: the method, the key id, the OAuth client, the User-Agent and any client certificate fingerprint | | `records_url`, `receipt_url` | Where to fetch the records and the signed receipt | | `validation_report`, `buyer_kit` | Present once ready. See [Compliance receipts](https://emailmovers.com/mydatahub/docs/compliance-receipts.md) | Every order records its caller in the audit trail and on invoices. A failed order is refunded in full and fires `order.failed`. ## Wait for the order An order moves from `queued` to `building`, `verifying` and `ready`. Subscribe to the `order.ready` [webhook](https://emailmovers.com/mydatahub/docs/webhooks.md), or poll `GET /v1/orders/{id}` or `get_order`. Records, downloads and the receipt answer `409 order-not-ready` until the order is ready. You can cancel while it is queued with `POST /v1/orders/{id}/cancel`. The credits come back in full, to the buckets they came from. Any later status answers `409 order-not-cancellable`, and records already delivered stay under licence. ## Fetch the records `GET /v1/orders/{id}/records` returns pages of delivered records as JSON or CSV, with the licence block on every page. The default `limit` is 50 and the maximum 500. A page stops at about 8,000 tokens, so it can hold fewer rows than `limit`, and it always carries a `next_cursor` when more remain. - **Pages.** Follow `next_cursor` until `has_more` is false. Over MCP, `fetch_order_records` holds at most 100 rows a page. - **Columns.** `fields` narrows a CSV to columns of the order's field set. - **Telephone screening.** A number's TPS and CTPS status is as of the screen date shown: `tps_ctps_status` is the result of the screen on `tps_ctps_screened_at`, valid until `tps_ctps_valid_until`, 28 days later. A clear number reads `not_screened`, with no dates, once that screen is more than 28 days before the day the file is made; a registered number stays registered. Screen the numbers you are supplied yourself before calling, and again every 28 days. - **Download.** `GET /v1/orders/{id}/download` redirects (302) to a single-use signed URL for the CSV. It lives 5 minutes and is bound to the account. - **Cost.** Fetching again under licence costs 0 credits. Records and the download stop after the licence ends, with the date in the problem, and the receipt stays readable. A record objected to after delivery stays in the page, so counts and cursors still line up, but its email, telephone number and name are blank and `compliance.suppression_status` says why. See [Suppressions and objections](https://emailmovers.com/mydatahub/docs/suppressions.md). ## Licence Every self-serve delivery is a 12 month multi-use licence, `multi_use_12m`. | Term | Value | | --- | --- | | Permitted channels | email, telephone and post | | Reuse | Until the order date plus 12 months. You can push to a CRM, re-download or refresh free until then | | Resale | Prohibited on standard terms | | At expiry | Records stay in your systems, the CRM property flags them, refreshes stop, and ordering again costs 1 credit | | Terms | Data Licence and Sharing Agreement, accepted by click at checkout | > **Proposed.** Outright ownership at 1.8 credits a record, which removes the expiry and the refresh entitlement, is a recommendation awaiting sign-off. A quote that asks for it is refused until it is switched on. ## Warranty The policy in force today replaces records when hard bounces exceed 10 percent of an order. Quotes show it as `replace_over_threshold_hard_bounce`, with the threshold each was issued under in `threshold_percent`. `GET /v1/plans` states the current threshold. > **Proposed.** The recommended policy replaces or re-credits any hard bounce within 30 days automatically, excludes catch-all addresses unless you opt in, and caps the automatic payout at 10 percent of an order. It is shown as `replace_any_hard_bounce_30d` once adopted. It is not in force. ## Enrich one contact `POST /v1/contacts/enrich` returns the full record for one known person, by email or by name and company. It charges 1 credit only on a confident match, and 0 when you already hold the record or nothing matches. It spends, so it follows the same rules as an order: an `Idempotency-Key`, and a person's confirmation or an agent key that lists `enrich_contact`. ## Test mode An `mdh_test_` key never charges. Its quote shows the price a live order would cost, and its order shows `credits_charged` 0 and leaves your live balance untouched. Test orders are visible only to test keys and announce no webhooks. A quote settles once, in one mode: a live key cannot order a quote that a test order used. A test key needs no balance, so an account with no credits can test its integration. ## Next steps - Understand confirmation in [Spending safely](https://emailmovers.com/mydatahub/docs/spending-safely.md). - Prove the list is lawful with [Compliance receipts](https://emailmovers.com/mydatahub/docs/compliance-receipts.md). - Push records onward: `POST /v1/orders/{id}/pushes` runs as a dry run unless you set `dry_run` to false, and a live push needs approval like a spend. --- # Spending safely > Three layers guard every spend: a machine quote first, a person's confirmation or a pre-authorised agent key, and hard limits. A flag set by a model is never consent. Source: https://emailmovers.com/mydatahub/docs/spending-safely Updated: 4 October 2026 No order is placed without a live quote and a person's say-so, and an agent can spend only inside limits a human set. Two actions spend credits: `create_order` (`POST /v1/orders`) and `enrich_contact` (`POST /v1/contacts/enrich`). A live CRM push needs the same approval, though it costs nothing. Everything else is free, so an agent can explore as much as it likes. > **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. Stripe Checkout top-ups and Stripe Shared Payment Tokens are planned and not built. ## The three layers 1. **Quote first.** An order without a live `quote_id` fails with "Call quote_order first; a quote is valid for 24 hours" and nothing is charged. It is idempotent on the quote plus the `Idempotency-Key`, so a retry never double-charges. 2. **Human proof.** A person confirms the spend, through a form, through an approval page, or because they set up an agent key. 3. **Hard limits.** A pre-authorised agent key skips the human step only while its per-order cap covers the quote and monthly budget remains. Money never moves inside a Claude connector or a ChatGPT app. Credits are deducted in the tool. Top-ups are planned to happen on mydatahub.emailmovers.com through Stripe Checkout, which is not built yet. ## Why a flag is not consent A flag set by a model is never consent. There is no `confirmed`, `confirm` or `consent` member anywhere. The MCP input schemas refuse unknown members, and the REST order body is strict, so a model cannot set one. A standard API key or an OAuth token alone never spends either. What a person decides never travels in the arguments a model writes. ## How an order is confirmed The order records the method in `confirmed_by.method`. | Method | Who confirms | How | | --- | --- | --- | | `app` | A person ordering in the app itself | Their signed-in session | | `approval_url` | A person on the approval page | A `confirmation_token` you send with the order | | `elicitation_form` | A person behind an MCP host | A form the host shows | | `agent_key` | A person who set the key's limits earlier | The order runs inside the key's cap and budget | A third-party OAuth client or a standard API key alone never counts as `app`, so there is no API key method. ## The approval page and the confirmation token With no confirmation, `POST /v1/orders` answers `403 confirmation-required` and nothing is charged. The problem carries the `quote_id`, an `expires_at` and an `approval_url` such as `https://mydatahub.emailmovers.com/quotes/{id}/approve`. ```json { "type": "https://emailmovers.com/mydatahub/docs/errors/confirmation-required", "title": "A person must confirm this spend", "status": 403, "code": "confirmation-required", "action": "order", "quote_id": "qte_01JB4M2R6T1Y5W8ZAK8Q2V7XN3", "approval_url": "https://mydatahub.emailmovers.com/quotes/qte_01JB4M2R6T1Y5W8ZAK8Q2V7XN3/approve" } ``` Give the link to the person. They sign in and approve, and the page returns a `confirmation_token`. Send it as `confirmation_token` in the order body. The token is bound to that person's `sub` and to this spend, and it is redeemable once. It lasts 15 minutes at most, and never longer than the quote, so retry the order promptly after approval because an expired token is refused. The approval page can also place the order itself when the person approves. ## The MCP form On a host with elicitation, `create_order` returns `resultType` `input_required` with a form of one boolean named `approve`, off by default. Its label states the spend: > Spend 5,000 credits (£1,249.00 ex VAT bought the cheapest way) for 5,000 contacts, 12 month multi-use licence The `requestState` is sealed with AEAD, following the [multi round trip pattern](https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr). It holds the principal, the quote id, a digest of the arguments and a 15 minute expiry, and it is redeemable once. The host asks the person and retries the same call with the answer and the state. - **Accepted.** The server mints the same confirmation token the approval page would, runs the order, and `confirmed_by.method` reads `elicitation_form`. - **Declined, cancelled or left unticked.** Nothing is placed and nothing is charged. The result is `confirmation-required` with the quote kept, so the agent does not ask again. - **Hosts without forms.** The server uses [URL-mode elicitation](https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation) to the approval page, which returns the token. - **Nobody to ask.** The tool returns the `confirmation-required` problem with the `approval_url`. > **Today.** A form is offered only when a form can speak for the caller. That is always so for a signed-in person behind the host. It is not so for an API key, because nothing proves a person sees a form that a key holder's client answers. In the current build every MCP caller is a key, so a standard key is sent to the approval page, and the form becomes the normal path once OAuth is live. ## Agent keys and budgets An agent key is an API key with `kind` agent, made by a signed-in person with a label, `allowed_tools`, `monthly_budget_credits`, `per_order_max_credits`, an expiry and an optional `webhook_url`. It never holds the `admin` scope. | Check | Outcome | | --- | --- | | The tool is not in `allowed_tools` | `403 insufficient-scope`, except for the three spends, which ask a person instead of shutting the key out | | The quote exceeds `per_order_max_credits` or the remaining monthly budget | `403 agent-budget-exceeded` | | The balance is short | `402 insufficient-credits` | | All limits cover it | The order is placed, with `confirmed_by.method` `agent_key` | Limits are checked before the balance, so an agent over its cap sees the 403 even when the balance is also short. Two orders cannot both fit the same budget, because the check runs inside the ledger transaction. A refund counts in the month of the charge it reverses, so a late refund cannot buy back a later month's budget. When a key spends on the authority of a person who is later removed or disabled, its next order, enrichment or live push answers `confirmation-required` and needs a person again. The `agent-budget-exceeded` problem carries the budget, the quote, an `approval_url` for the key owner and a `top_up_url`. The owner is told only when the key has a `webhook_url` and an endpoint is registered at that exact URL, so the notice can be signed. See the `webhook_url` row in [Standard keys and agent keys](https://emailmovers.com/mydatahub/docs/authentication.md#standard-keys-and-agent-keys). ```json { "budget": { "monthly_credits": 1000, "spent": 200, "remaining": 800, "resets_at": "2026-10-01T00:00:00Z" }, "per_order_max_credits": 500, "quote_id": "qte_01JB4M2R6T1Y5W8ZAK8Q2V7XN3", "credits_required": 5000 } ``` ## When the balance is short `POST /v1/orders` answers `402 insufficient-credits` with the quote and a `top_up_url` on mydatahub.emailmovers.com, where a Stripe Checkout page is planned and not built yet. Once a top-up is possible, retry with the same `quote_id`. Stripe Shared Payment Tokens for agent wallets are planned and not built. Machine Payments Protocol on the order endpoint is planned for phase 2. ## Retries and idempotency `Idempotency-Key` is required on `POST /v1/orders` and `POST /v1/contacts/enrich`, and accepted on every other creating POST. Over MCP, `create_order` takes it as the `idempotency_key` argument. - The same key with the same request returns the stored response, with `Idempotent-Replayed: true`. Keys are kept for the life of the organisation. - The same key with a different request is `409 idempotency-key-reused`. - A refusal (402, 403 or 422) is not stored, so the same key works after you fix the cause. - The ledger has its own key built from the thing being paid for, so a different `Idempotency-Key` still cannot charge one quote twice. ## No human, no key When no person is present and no key covers the spend, no order is created. The agent keeps the `quote_id`, a human approves it later in the app, and `order.ready` fires when the records are built. A colleague's agent in the same organisation can read the quote with `GET /v1/quotes/{id}` and order it under its own key. Outside the organisation the audience is quoted again on the payer's plan, and the original quote is never charged. ## Next steps - See every problem in [Errors](https://emailmovers.com/mydatahub/docs/errors.md), starting with [confirmation-required](https://emailmovers.com/mydatahub/docs/errors/confirmation-required.md) and [agent-budget-exceeded](https://emailmovers.com/mydatahub/docs/errors/agent-budget-exceeded.md). - Create keys with [Authentication](https://emailmovers.com/mydatahub/docs/authentication.md). - Get told the moment an order is ready with [Webhooks](https://emailmovers.com/mydatahub/docs/webhooks.md). --- # Suppressions and objections > Upload suppression lists that apply to every count and order, check records before a send, and report an objection so it reaches every licensee holding the record. Source: https://emailmovers.com/mydatahub/docs/suppressions Updated: 4 October 2026 Suppression runs both ways. You upload your own lists, and an objection reported by anyone reaches every organisation that holds the record. Lists are free, apply to every later count and order, and are stored as SHA-256 hashes only. Everyone has an absolute right to object to direct marketing, and it is kept by suppression, not erasure. > **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. ## Upload a list `POST /v1/suppressions` takes a `name`, a `kind` and up to 100,000 `values`. ```json { "name": "Existing customers", "kind": "email", "values": ["jo.example@example.co.uk", "sam@example.org"] } ``` | `kind` | What the values are | How they are read | | --- | --- | --- | | `email` | Email addresses | Trimmed, lower cased and hashed | | `domain` | Domains | Trimmed, lower cased and hashed. `*.acme.co.uk` is a suffix match, and a wildcard needs a domain, not `*.com` | | `phone` | Telephone numbers | Read as E.164 digits: `0113 234 5671` becomes `441132345671` | | `company_number` | Companies House numbers | Upper case and padded to 8 characters: `5046417` becomes `05046417` | | `title_term` | Words in a job title | Kept as words, at most five, never hashed | The normalisation follows the advertising platform convention, so an existing suppression file works unchanged. If you already hold SHA-256 hashes, send `hashed` true with 64 lower case hex characters per value. The response tells you what it read. A repeated value is skipped, the first 100 skipped rows are named with a reason, and `rows_read` less `rows_valid` counts them all. A list with nothing valid is a 422. ```json { "list": { "id": "sup_01JB4H7Q2V7XN3M9R6T1Y5W8ZA", "name": "Existing customers", "kind": "email", "row_count": 1198 }, "rows_read": 1204, "rows_valid": 1198, "rows_skipped": [{ "row": 17, "reason": "Not an email address." }] } ``` A suppression list is processed under the Article 28 data processing agreement, so your organisation accepts it before the first upload. A person who owns the account accepts it in the app's Settings, or with `POST /v1/account/agreements` and `{"dpa_accepted_version": "1.0"}`, the version `GET /v1/account` names in `agreements.dpa`. The app's first upload asks with a box of its own. Until it is accepted, an upload answers 403 `dpa-acceptance-required` and nothing is uploaded. When a new version is in force, it is accepted again. ## Use a list in an audience Name the list in an `in_list` filter, usually under `not`. ```json { "not": { "field": "email.address_sha256", "op": "in_list", "list_id": "sup_01JB4H7Q2V7XN3M9R6T1Y5W8ZA" } } ``` Each count reports what each list removed in `compliance_summary.suppressed_removed`, as `{list_id, removed}`. Title term and domain wildcard lists report the same way. Suppressed records never appear in counts or orders, and a matched record is never charged. See [The audience definition](https://emailmovers.com/mydatahub/docs/audience.md). ## Check before you send `POST /v1/suppressions/check` takes up to 1,000 `record_ids` and returns the status of each as of now. Run it before any send. ```json { "record_ids": ["contact_01JA9X2V7XN3M9R6T1Y5W8ZAK8"] } ``` | `suppression_status` | Meaning | | --- | --- | | `none` | Not suppressed | | `objected_to_emailmovers` | The person objected to Emailmovers, so the record is suppressed database-wide | | `objected_to_licensee` | The person objected to an organisation that holds the record, and it is on that organisation's objections list | | `deceased` | The person has died | | `left_company` | The person has left the company | Each result also says `suppressed` and which of your `list_ids` hold the record. A record the current dataset no longer holds is still found if it was objected to. ## Report an objection `POST /v1/objections`, or `report_objection` over MCP, records an objection to direct marketing. Send exactly one of `record_id` or `email_sha256`, with the `channel` it arrived by, when it was `received_at` and a `scope`. ```json { "record_id": "contact_01JA9X2V7XN3M9R6T1Y5W8ZAK8", "channel": "email", "received_at": "2026-10-01T08:30:00Z", "scope": "emailmovers", "note": "Replied asking not to be contacted again." } ``` | `scope` | Effect | | --- | --- | | `licensee` | Suppresses the record for your organisation at once. The response is `201` with status `suppressed` | | `emailmovers` | Suppresses it database-wide. The response is `201` with status `pending` and an `effective_by` time of 17:00 UTC on the next working day | The record goes on your objections list immediately for both scopes. A database-wide objection then reaches every licensee through a same-day overlay that is subtracted from every count, sample and order, and each organisation holding the record gets a signed `suppression.created` webhook. The webhook carries the record, the reason, the scope and the time. It never says who reported it or what the note said. Because a licensee must not be able to remove records from every other customer's counts unchecked, scope `emailmovers` has three conditions. 1. You must hold the record, from a delivered live order or an enrichment. Otherwise it is a 422 that advises scope `licensee`, which still protects the person for your organisation at once. 2. An organisation may report 200 a day. Beyond that the answer is a 429 with the same advice. 3. It costs one unit of your counts budget. See [Rate limits](https://emailmovers.com/mydatahub/docs/rate-limits.md). ## Pull instead of waiting `GET /v1/suppressions?since=` returns the lists changed after a time, each with `added_since`, the notices for records added to it. Use it to catch up after downtime, alongside the `suppression.created` [webhook](https://emailmovers.com/mydatahub/docs/webhooks.md). ## What a delivered record looks like after an objection A record objected to after delivery stays in your records pages and downloads, so counts, cursors and your CRM's own key still line up. Its email, telephone number and name are blank, and `compliance.suppression_status` says why. A file downloaded after that no longer hashes to the `content_sha256` on the receipt, which records the day of delivery. See [Compliance receipts](https://emailmovers.com/mydatahub/docs/compliance-receipts.md). ## Deadlines and law The right to object to direct marketing is absolute under UK GDPR, and an objection must be honoured within one month. Here it takes effect for your organisation at once, and database-wide within one working day. Telephone screening is separate: every telephone number is screened against the TPS and CTPS each night through a licensed screening service, and carries its status and the date of that screen, valid for 28 days. ## Next steps - Check the objection and list endpoints in the [REST reference](https://emailmovers.com/mydatahub/docs/reference/rest.md). - Read how receipts count suppressed records in [Compliance receipts](https://emailmovers.com/mydatahub/docs/compliance-receipts.md). - Subscribe to `suppression.created` in [Webhooks](https://emailmovers.com/mydatahub/docs/webhooks.md). --- # Compliance receipts > Every order issues a signed receipt that proves what was delivered and how it was screened. Verify its ES256 signature with the published key, or with one request and no account. Source: https://emailmovers.com/mydatahub/docs/compliance-receipts Updated: 4 October 2026 Every order issues a compliance receipt: a compact JWS signed with ES256 that counts the records delivered, the suppressions removed, the subscriber classes, the TPS and CTPS screens and the Article 14 status. Anyone can verify it with the published key, or with `POST /v1/compliance/receipts/verify` and no account. One agent can therefore prove to another that a list is licensed and screened. > **Not yet live.** The public key set is served at `https://mydatahub.emailmovers.com/.well-known/jwks.json` once v2 launches. The local build serves it at `/.well-known/jwks.json` on the API host. ## Get the receipt `GET /v1/orders/{id}/receipt` and `get_compliance_receipt` return it once the order is ready. A PDF twin is at `mydatahub.emailmovers.com/receipts/{id}`. The response has the compact `jws`, its `alg` and `kid`, the `jwks_url`, and the decoded `claims`. ```json { "id": "rcpt_01JB4NA2Q5W8ZAK8Q2V7XN3M9R", "order_id": "ord_01JB4N9S3W2X6Y7Z8A1B2C3D4E", "alg": "ES256", "kid": "rcpt-2026-09", "jwks_url": "https://mydatahub.emailmovers.com/.well-known/jwks.json", "pdf_url": "https://mydatahub.emailmovers.com/receipts/rcpt_01JB4NA2Q5W8ZAK8Q2V7XN3M9R", "claims": { "receipt_id": "rcpt_01JB4NA2Q5W8ZAK8Q2V7XN3M9R", "order_id": "ord_01JB4N9S3W2X6Y7Z8A1B2C3D4E", "issued_at": "2026-09-26T10:16:04Z", "dataset_version": "2026-W39", "records_delivered": 5000, "removed_by_suppression": 41 } } ``` ## What the claims say | Claim | Meaning | | --- | --- | | `receipt_id`, `order_id`, `issued_at` | Which receipt, for which order, and when | | `dataset_version` | The immutable build the records came from | | `records_delivered`, `removed_by_suppression` | What you received and what was left out | | `counts.subscriber_class` | Records by `ltd`, `plc`, `llp`, `scottish_partnership`, `public_body`, `charity_incorporated`, `sole_trader`, `ordinary_partnership` and `unknown` | | `counts.tps_ctps_status` | Records `clear`, `tps_registered`, `ctps_registered`, `tps_or_ctps_registered` or `not_screened` | | `counts.article14_status` | Records `notified`, `publicly_notified_disproportionate_effort` or `pending_within_window` | | `lawful_basis`, `lia_ref` | UK GDPR Article 6(1)(f) legitimate interests, and the published assessment | | `licence` | The licence block: type, `reuse_until`, channels, warranty and terms version | | `residency_statement_version`, `sub_processor_list_version` | Which versions of those published statements applied | | `verified_within_days` | The share of delivered emails verified within 30, 60 and 90 days | | `content_sha256` | SHA-256 of the delivered file, in lower case hex | ## Verify with one request `POST /v1/compliance/receipts/verify` takes the JWS and needs no account. ```bash curl -X POST "https://mydatahub.emailmovers.com/v1/compliance/receipts/verify" \ -H "Content-Type: application/json" \ --data '{ "jws": "eyJhbGciOiJFUzI1NiIs..." }' ``` ```json { "valid": true, "order_exists": true, "licence_in_force_until": "2027-09-26", "revoked": false, "receipt_id": "rcpt_01JB4NA2Q5W8ZAK8Q2V7XN3M9R", "checked_at": "2026-10-01T09:00:00Z" } ``` Read the three answers separately. - **`valid`** is the signature alone. It says the receipt was signed by a published MyDataHub key, with the receipt type in the header. - **`order_exists`** says whether the order is on record. - **`licence_in_force_until`** is a date only when the order is on record, live, not revoked and not ended. It is `null` otherwise, so a signature alone never reads as a licence. A JWS that does not verify returns `valid` false and nothing else. Its claims are never read. This operation is REST only, because the 22 MCP tools are fixed. ## Verify offline You can check a receipt with no call to us. Fetch the key set, verify the signature, and check the `typ` header, because a quote and a confirmation token are signed by the same key. A receipt's protected header has `typ` set to `mydatahub.receipt+jws`. Receipts signed before a key rotation still verify, because the key set lists every retired key. ```ts // verify-receipt.ts import { compactVerify, createRemoteJWKSet } from "jose"; const jwks = createRemoteJWKSet(new URL("https://mydatahub.emailmovers.com/.well-known/jwks.json")); export async function verifyReceipt(jws: string): Promise> { const { payload, protectedHeader } = await compactVerify(jws, jwks, { algorithms: ["ES256"] }); if (protectedHeader.typ !== "mydatahub.receipt+jws") throw new Error("This JWS is not a receipt."); return JSON.parse(new TextDecoder().decode(payload)) as Record; } ``` This needs the `jose` package. Then check the file you were given against the receipt. ```ts // check-file.ts import { createHash } from "node:crypto"; import { readFile } from "node:fs/promises"; export async function matchesReceipt(path: string, claims: { content_sha256: string }): Promise { const digest = createHash("sha256").update(await readFile(path)).digest("hex"); return digest === claims.content_sha256; } ``` The hash records the file on the day of delivery. A copy downloaded later no longer matches once a record in it was objected to, which blanks that person's details, or once a number's clear TPS and CTPS screen is more than 28 days old, which makes it read `not_screened`. See [Suppressions and objections](https://emailmovers.com/mydatahub/docs/suppressions.md). ## The compliance object on every record Every preview row, order record, lookup and enrichment carries a `compliance` object, repeated as CSV columns and as the MyDataHub property group on a CRM push. | Member | Values | | --- | --- | | `lawful_basis` | `legitimate_interests_art6_1_f` | | `subscriber_class` | `ltd`, `plc`, `llp`, `scottish_partnership`, `public_body`, `charity_incorporated`, `sole_trader`, `ordinary_partnership` or `unknown` | | `email_marketing_basis` | `pecr_corporate_subscriber` or `consent_required` | | `article14_status` | `notified`, `publicly_notified_disproportionate_effort` or `pending_within_window`, with `article14_notified_at` and `notice_version` | | `tps_ctps_status` | `clear`, `tps_registered`, `ctps_registered`, `tps_or_ctps_registered` or `not_screened`: the result of the screen on `tps_ctps_screened_at`, valid until `tps_ctps_valid_until`. In a sample, an order's records (each time they are fetched, downloaded or pushed) and an enrichment, a clear number reads `not_screened` once that screen is more than 28 days before the day the record is served | | `suppression_status` | `none`, `objected_to_emailmovers`, `objected_to_licensee`, `deceased` or `left_company` | | `provenance` | The source of the company, contact and email, with dates, the verification method and the provider location | Companies House company data is under the [Open Government Licence v3.0](https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/) and carries its attribution. Officer names are personal data outside that licence, so they carry an Article 14 status instead. ## The buyer kit Each order returns `buyer_kit` with four signed URLs. - A draft legitimate interests assessment. - First-email wording that satisfies Article 14 at first communication. - An Article 30 stub for your record of processing. - A due diligence file with the receipt, the validation report and the order's audit trail. The documents are made in the app, and the links appear once they exist. ## How it answers the ICO The [ICO's guidance for buyers of broker data](https://ico.org.uk/for-organisations/direct-marketing-and-privacy-and-electronic-communications/organisations-using-marketing-services-of-data-brokers/) says accepting a broker's assurances is not enough, so the evidence is fields and not badges. | ICO question | Where to look | | --- | --- | | Who compiled the data? | `provenance.contact.source`, and the data owner statement on the trust page | | Where was it obtained from? | The source per field in `provenance`, and the Companies House attribution | | What privacy information was used? | `article14_status`, `article14_notified_at` and `notice_version` | | When was it compiled and how old is it? | `compiled_at`, `last_verified_at`, `record_age_days` and `dataset_version` | | How was it collected? | `provenance.contact.method` and the verification provider and location | | What consent records exist? | None claimed. The basis is legitimate interests, with `email_marketing_basis` per record | | Was it screened against opt-out and suppression lists? | `tps_ctps_status` and its screen date, `suppression_status`, `suppressed_removed` per count and the receipt counts | The trust page is also data: `GET /v1/trust`, `GET /v1/compliance/lia`, the `mydatahub://compliance` resource and `https://mydatahub.emailmovers.com/trust.json`. ## Next steps - Read how a receipt is made in [Quotes and orders](https://emailmovers.com/mydatahub/docs/quotes-and-orders.md). - Verify one now with [REST reference: Verify a compliance receipt](https://emailmovers.com/mydatahub/docs/reference/rest/verify-compliance-receipt.md). - Look at the trust data with [REST reference: Get the trust document](https://emailmovers.com/mydatahub/docs/reference/rest/get-trust.md). --- # Versions and reproducibility > A count id re-runs exactly for 12 months on its pinned dataset version. The API evolves by dated version, and the path major changes only for breaking changes. Source: https://emailmovers.com/mydatahub/docs/versions Updated: 4 October 2026 Three things are versioned, and each is pinned so a result can be reproduced. The API has a dated `MyDataHub-Version`, the data has an immutable weekly `dataset_version`, and the counting rules have an `engine_rules_version`. A count id hashes the last two, so the same definition on the same data gives the same id for 12 months. > **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. The Monday build schedule and the Companies House streams are planned and not built. ## API versions The base is `https://mydatahub.emailmovers.com/v1`. Within `/v1`, behaviour changes by date. - Send `MyDataHub-Version: 2027-01-15` to pin a version. Leave it out and your key's `api_version` applies. - Non-breaking changes ship as a new date. An unsupported date answers [unsupported-version](https://emailmovers.com/mydatahub/docs/errors/unsupported-version.md). - The path major changes only for a breaking change, and the old major stays available for 12 months. There are no `/v2` paths. - Response objects are open. A new member may appear in a later version, so ignore members you do not know. Request bodies are closed and refuse unknown members. - A webhook endpoint is pinned to the version of the request that created it, and every event carries its `api_version`. `2027-01-15` is the only version so far, and it is the one these docs describe. Lists use cursor pagination and return `{data, has_more, next_cursor}`, with `limit` defaulting to 25 and a maximum of 100 (500 on records). ## Dataset versions Every Monday the pipeline builds an immutable `dataset_version`, such as `2026-W39`, and flips the current pointer. The last 52 stay queryable, so any count from the last 12 months re-runs exactly. The plan is for Companies House changes to land within 15 minutes through its streaming feeds and to appear in the next build. The streams are not built: the build reads Companies House data as files. Opt-outs, objections and erasure requests do not wait for Monday. A same-day overlay is subtracted from every count and delivery. A count replayed on its pinned version therefore reports `removed_since` beside the original number. `GET /v1/datasets` lists each dataset with its owner, source, licence, refresh, residency, regulator, warranty and `credits_per_record`. Version 2.0 serves `uk_contacts` only. `ie_contacts` and `global_contacts` are listed but not yet served, and asking for them answers [dataset-unavailable](https://emailmovers.com/mydatahub/docs/errors/dataset-unavailable.md). Each version publishes what changed at `GET /v1/datasets/uk_contacts/versions/2026-W39`. | Member | What it holds | | --- | --- | | `row_counts` | Companies, sites, contacts, emails and phones in the build | | `changes` | Records added, retired and re-verified, companies dissolved, job changes confirmed and suppressions applied | | `coverage_pct` | Coverage by field | | `input_snapshots` | The source snapshots the build used, with their dates | | `checksum` | A checksum of the build | ## How a count id is made A count id is content addressed. It is `cnt_` followed by the first 20 base32 characters of the SHA-256 of three things in [RFC 8785](https://www.rfc-editor.org/rfc/rfc8785) canonical JSON: 1. The effective definition, after the server fills its defaults and puts it in canonical form. 2. The `dataset_version`. 3. The `engine_rules_version`. The canonical form sorts and deduplicates `in` and `since` values, terms and expansion sets, flattens nested groups of the same kind, unwraps a group of one and a double `not`, and upper cases postcodes. A bundle is replaced by its fragment. The definition reaches the same id from chips, REST or MCP, so there is one id and one cache entry. The id is the same for every caller, level and breakdown. ## Replay a count `GET /v1/counts/{id}` returns the count as it ran, worked out again for the caller's own tier, so an anonymous caller who knows a paid caller's id sees rounded numbers. It returns: - the effective definition and the compiled plan, with the expanded terms, the resolved centroid and cells, and the SIC codes behind a business class, - the pinned `dataset_version` and `engine_rules_version`, - all three views, the breakdowns, the compliance summary and the quality block, - `removed_since`, the Total as it ran less the Total now, and - `expires_at`, 12 months after the count ran. The `ETag` equals the id, so a client that holds a count can ask with `If-None-Match` and get `304`. A count that read your organisation's private suppression lists belongs to your organisation, and anyone else gets 404. ## Quotable counts Because a count replays, it can be cited. The app shows each count at `mydatahub.emailmovers.com/counts/{id}` with a "Cite this count" button. It copies a line such as "Source: MyDataHub by Emailmovers, 1,605,275 named UK business contacts, as of 26 September 2026" and the count URL. We never put a count or a price behind sign-up. ## Reproducibility checklist - Save the `id` of every count you rely on. - Keep the `dataset_version` and `engine_rules_version` it returned. - Quote against the count id, so the quote is bound to the same dataset version. - Re-run with `GET /v1/counts/{id}` within 12 months, and read `removed_since`. ## Next steps - Count with [Counts and breakdowns](https://emailmovers.com/mydatahub/docs/counts.md). - Read what changed and what is going in [Changelog and deprecation policy](https://emailmovers.com/mydatahub/docs/changelog.md). - See the headers and the cache rules in [Rate limits](https://emailmovers.com/mydatahub/docs/rate-limits.md). --- # The request could not be read > invalid-request: HTTP 400. Send a JSON body with Content-Type: application/json and check the query string. Source: https://emailmovers.com/mydatahub/docs/errors/invalid-request `invalid-request` is returned with HTTP status 400. Its type URI is `https://emailmovers.com/mydatahub/docs/errors/invalid-request`. ## What it means The API could not read the request. The body is not valid JSON, the Content-Type is not application/json, or a query parameter cannot be read. ## What to do Send a JSON body with Content-Type: application/json and check the query string. ## Example ```json { "type": "https://emailmovers.com/mydatahub/docs/errors/invalid-request", "title": "The request could not be read", "status": 400, "code": "invalid-request", "detail": "The request body is not valid JSON.", "next_step": "Send a JSON body with Content-Type: application/json and check the query string." } ``` ## See also - [Quickstart for developers](https://emailmovers.com/mydatahub/docs/quickstart/developers.md) All problem types are listed on [Errors](https://emailmovers.com/mydatahub/docs/errors.md). --- # An Idempotency-Key header is required > idempotency-key-required: HTTP 400. Send an Idempotency-Key header (a V4 UUID is a good choice) and reuse it on every retry. Source: https://emailmovers.com/mydatahub/docs/errors/idempotency-key-required `idempotency-key-required` is returned with HTTP status 400. Its type URI is `https://emailmovers.com/mydatahub/docs/errors/idempotency-key-required`. ## What it means POST /v1/orders and POST /v1/contacts/enrich spend credits, so both need an Idempotency-Key header. Nothing was charged. ## What to do Send an Idempotency-Key header (a V4 UUID is a good choice) and reuse it on every retry. ## Example ```json { "type": "https://emailmovers.com/mydatahub/docs/errors/idempotency-key-required", "title": "An Idempotency-Key header is required", "status": 400, "code": "idempotency-key-required", "detail": "POST /v1/orders needs an Idempotency-Key header.", "next_step": "Send an Idempotency-Key header (a V4 UUID is a good choice) and reuse it on every retry." } ``` ## See also - [Quotes and orders](https://emailmovers.com/mydatahub/docs/quotes-and-orders.md) - [Spending safely](https://emailmovers.com/mydatahub/docs/spending-safely.md) All problem types are listed on [Errors](https://emailmovers.com/mydatahub/docs/errors.md). --- # Unsupported MyDataHub-Version > unsupported-version: HTTP 400. Send a MyDataHub-Version date the API lists, such as 2027-01-15, or leave it out. Source: https://emailmovers.com/mydatahub/docs/errors/unsupported-version `unsupported-version` is returned with HTTP status 400. Its type URI is `https://emailmovers.com/mydatahub/docs/errors/unsupported-version`. ## What it means The MyDataHub-Version header names a date this API does not serve. ## What to do Send a MyDataHub-Version date the API lists, such as 2027-01-15, or leave it out. ## Example ```json { "type": "https://emailmovers.com/mydatahub/docs/errors/unsupported-version", "title": "Unsupported MyDataHub-Version", "status": 400, "code": "unsupported-version", "detail": "Version 2026-01-01 is not served.", "next_step": "Send a MyDataHub-Version date the API lists, such as 2027-01-15, or leave it out." } ``` ## See also - [Versions and reproducibility](https://emailmovers.com/mydatahub/docs/versions.md) All problem types are listed on [Errors](https://emailmovers.com/mydatahub/docs/errors.md). --- # Sign in or send an API key > authentication-required: HTTP 401. Send Authorization: Bearer with an mdh_ API key. Sign-in through https://mydatahub.emailmovers.com/id is planned. Source: https://emailmovers.com/mydatahub/docs/errors/authentication-required `authentication-required` is returned with HTTP status 401. Its type URI is `https://emailmovers.com/mydatahub/docs/errors/authentication-required`. ## What it means The operation needs a credential and none was sent, or the one sent is not valid. The response carries a WWW-Authenticate challenge asking for three read scopes. ## What to do Send Authorization: Bearer with an mdh_ API key. Sign-in through https://mydatahub.emailmovers.com/id is planned. ## Example ```json { "type": "https://emailmovers.com/mydatahub/docs/errors/authentication-required", "title": "Sign in or send an API key", "status": 401, "code": "authentication-required", "detail": "Send an API key as Authorization: Bearer.", "next_step": "Send Authorization: Bearer with an mdh_ API key. Sign-in through https://mydatahub.emailmovers.com/id is planned." } ``` ## See also - [Authentication](https://emailmovers.com/mydatahub/docs/authentication.md) All problem types are listed on [Errors](https://emailmovers.com/mydatahub/docs/errors.md). --- # This needs a free account > account-required: HTTP 401. Create a free account (100 credits, no card) for single company counts, more than 6 filters in one audience, free text title search or a radius under 5 miles. Free and trial accounts still see rounded small counts; exact figures come with a paid plan. Source: https://emailmovers.com/mydatahub/docs/errors/account-required `account-required` is returned with HTTP status 401. Its type URI is `https://emailmovers.com/mydatahub/docs/errors/account-required`. ## What it means The anonymous tier does not cover the request. A single company count, more than 6 filters in one audience, free text title search and a radius under 5 miles each need a free account. Rounded small counts are not an error: free and trial accounts still see them, and exact figures come with a paid plan. ## What to do Create a free account (100 credits, no card) for single company counts, more than 6 filters in one audience, free text title search or a radius under 5 miles. Free and trial accounts still see rounded small counts; exact figures come with a paid plan. ## Example ```json { "type": "https://emailmovers.com/mydatahub/docs/errors/account-required", "title": "This needs a free account", "status": 401, "code": "account-required", "detail": "A radius under 5 miles needs a free account.", "next_step": "Create a free account (100 credits, no card) for single company counts, more than 6 filters in one audience, free text title search or a radius under 5 miles. Free and trial accounts still see rounded small counts; exact figures come with a paid plan." } ``` ## See also - [Authentication](https://emailmovers.com/mydatahub/docs/authentication.md) - [Rate limits](https://emailmovers.com/mydatahub/docs/rate-limits.md) All problem types are listed on [Errors](https://emailmovers.com/mydatahub/docs/errors.md). --- # Not enough credits > insufficient-credits: HTTP 402. Top up at the top_up_url, then retry with the same machine quote_id (or the same enrichment request). Source: https://emailmovers.com/mydatahub/docs/errors/insufficient-credits `insufficient-credits` is returned with HTTP status 402. Its type URI is `https://emailmovers.com/mydatahub/docs/errors/insufficient-credits`. ## What it means The balance does not cover the machine quote. Nothing was charged. The problem carries the quote, the credits required and available, and a Stripe Checkout top_up_url on mydatahub.emailmovers.com. Money never moves inside an agent host: a person tops up on our domain, then the same quote_id is retried. ## What to do Top up at the top_up_url, then retry with the same machine quote_id (or the same enrichment request). ## Members it adds Beyond `type`, `title`, `status`, `detail`, `code` and `next_step`, this problem carries `quote_id`, `quote`, `credits_required`, `credits_available` and `top_up_url`. They are published in the `InsufficientCreditsProblem` schema of the OpenAPI document. ## Example ```json { "type": "https://emailmovers.com/mydatahub/docs/errors/insufficient-credits", "title": "Not enough credits", "status": 402, "code": "insufficient-credits", "detail": "The quote needs 5,000 credits and the balance is 1,200.", "next_step": "Top up at the top_up_url, then retry with the same machine quote_id (or the same enrichment request).", "quote_id": "qte_01JB4M2R6T1Y5W8ZAK8Q2V7XN3", "credits_required": 5000, "credits_available": 1200, "top_up_url": "https://mydatahub.emailmovers.com/billing/top-up" } ``` ## See also - [Spending safely](https://emailmovers.com/mydatahub/docs/spending-safely.md) - [Quotes and orders](https://emailmovers.com/mydatahub/docs/quotes-and-orders.md) All problem types are listed on [Errors](https://emailmovers.com/mydatahub/docs/errors.md). --- # The token lacks a scope > insufficient-scope: HTTP 403. Ask the user to approve the scope named in required_scope, then retry. Source: https://emailmovers.com/mydatahub/docs/errors/insufficient-scope `insufficient-scope` is returned with HTTP status 403. Its type URI is `https://emailmovers.com/mydatahub/docs/errors/insufficient-scope`. ## What it means The token or key does not hold a scope the operation needs. required_scope names it, and a WWW-Authenticate step-up challenge lets an OAuth client ask for it. ## What to do Ask the user to approve the scope named in required_scope, then retry. ## Members it adds Beyond `type`, `title`, `status`, `detail`, `code` and `next_step`, this problem carries `required_scope`. They are published in the `Problem` schema of the OpenAPI document. ## Example ```json { "type": "https://emailmovers.com/mydatahub/docs/errors/insufficient-scope", "title": "The token lacks a scope", "status": 403, "code": "insufficient-scope", "detail": "This operation needs the orders:write scope.", "next_step": "Ask the user to approve the scope named in required_scope, then retry.", "required_scope": "orders:write" } ``` ## See also - [Authentication](https://emailmovers.com/mydatahub/docs/authentication.md) All problem types are listed on [Errors](https://emailmovers.com/mydatahub/docs/errors.md). --- # A person must confirm this spend > confirmation-required: HTTP 403. Nothing was charged. Keep the machine quote_id and give the user the approval_url; retry with the confirmation_token it returns, or use an agent key whose limits cover the spend. Source: https://emailmovers.com/mydatahub/docs/errors/confirmation-required `confirmation-required` is returned with HTTP status 403. Its type URI is `https://emailmovers.com/mydatahub/docs/errors/confirmation-required`. ## What it means No person confirmed this spend and no agent key covers it. A flag set by a model is never consent. Nothing was charged and no order was created. Keep the machine quote_id. A person approves it at approval_url, or an agent key whose limits cover the spend places the order. ## What to do Nothing was charged. Keep the machine quote_id and give the user the approval_url; retry with the confirmation_token it returns, or use an agent key whose limits cover the spend. ## Members it adds Beyond `type`, `title`, `status`, `detail`, `code` and `next_step`, this problem carries `action`, `quote_id`, `approval_url` and `expires_at`. They are published in the `ConfirmationRequiredProblem` schema of the OpenAPI document. ## Example ```json { "type": "https://emailmovers.com/mydatahub/docs/errors/confirmation-required", "title": "A person must confirm this spend", "status": 403, "code": "confirmation-required", "detail": "A person must approve this order.", "next_step": "Nothing was charged. Keep the machine quote_id and give the user the approval_url; retry with the confirmation_token it returns, or use an agent key whose limits cover the spend.", "action": "order", "quote_id": "qte_01JB4M2R6T1Y5W8ZAK8Q2V7XN3", "approval_url": "https://mydatahub.emailmovers.com/quotes/qte_01JB4M2R6T1Y5W8ZAK8Q2V7XN3/approve", "expires_at": "2026-09-27T10:14:00Z" } ``` ## See also - [Spending safely](https://emailmovers.com/mydatahub/docs/spending-safely.md) All problem types are listed on [Errors](https://emailmovers.com/mydatahub/docs/errors.md). --- # Business details are needed before the first order > business-details-required: HTTP 403. Nothing was charged. A person who owns the account confirms the business name and that it is a UK business, in the app's Settings or with PATCH /v1/account, then the same machine quote_id is ordered again. Source: https://emailmovers.com/mydatahub/docs/errors/business-details-required `business-details-required` is returned with HTTP status 403. Its type URI is `https://emailmovers.com/mydatahub/docs/errors/business-details-required`. ## What it means An account made by self-serve sign-up places its first order or enrichment, free or paid, only once a person who owns it has confirmed its business name and that it is a UK business. Nothing was charged and no order was created. A person who owns the account confirms both in the app's Settings, or with PATCH /v1/account, which takes a signed-in person and never an API key. Then the same request is sent again, an order with the same machine quote_id. Accounts Emailmovers set up, and spends with a test key, are not asked. ## What to do Nothing was charged. A person who owns the account confirms the business name and that it is a UK business, in the app's Settings or with PATCH /v1/account, then the same machine quote_id is ordered again. ## Example ```json { "type": "https://emailmovers.com/mydatahub/docs/errors/business-details-required", "title": "Business details are needed before the first order", "status": 403, "code": "business-details-required", "detail": "This account was made by self-serve sign-up, and before its first order a person who owns it confirms the business name and that it is a UK business. Nothing was charged.", "next_step": "Nothing was charged. A person who owns the account confirms the business name and that it is a UK business, in the app's Settings or with PATCH /v1/account, then the same machine quote_id is ordered again." } ``` ## See also - [Quotes and orders](https://emailmovers.com/mydatahub/docs/quotes-and-orders.md) - [Spending safely](https://emailmovers.com/mydatahub/docs/spending-safely.md) All problem types are listed on [Errors](https://emailmovers.com/mydatahub/docs/errors.md). --- # Authentication > API keys and agent keys authenticate REST and MCP clients, AI assistants among them. OAuth 2.1 at mydatahub.emailmovers.com/id signs you in to the app; OAuth sign-in for REST and MCP clients is planned for after launch. Source: https://emailmovers.com/mydatahub/docs/authentication Updated: 16 October 2026 One identity works everywhere, and an API key is a bearer token on REST and MCP alike. OAuth 2.1 with PKCE, issued by `https://mydatahub.emailmovers.com/id`, signs you in to the app. At launch, REST and MCP clients, AI assistants among them, use an API key; OAuth sign-in for them is planned for after launch. You need neither a key nor a sign-in to look: the catalogue, counts, previews, quotes and plans work with no token, and the first 401 arrives only when an operation needs your account. > **Not yet live.** MyDataHub v2 is built and tested locally. API keys are the way in first. `mydatahub.emailmovers.com/id` does not exist yet, so a bearer token that is not an API key is refused. > **To confirm.** The plan is for the current issuer on `mcp.mydatahub.uk` to run alongside the new one for 90 days after launch, so that today's installs keep working. That is not built: the v2 API refuses any bearer token that is not an `mdh_` key. ## API keys A key is `mdh_live_` or `mdh_test_` followed by 32 letters and digits. | Prefix | Behaviour | | --- | --- | | `mdh_test_` | Returns synthetic data and never charges | | `mdh_live_` | Reads your real account and spends real credits | Send it in the `Authorization` header, never in a custom header or the query string. ```bash curl "https://mydatahub.emailmovers.com/v1/account" \ -H "Authorization: Bearer $MYDATAHUB_API_KEY" ``` Keys are made in the app at `/api-keys` by a signed-in person, and the secret is shown once. Only a hash is kept, so a lost secret means a new key. No API key can make or change keys, even with the `admin` scope, so no key can raise its own limits. A key can carry an `ip_allowlist` of CIDR ranges, and an expiry. ## Standard keys and agent keys | | Standard key | Agent key | | --- | --- | --- | | `kind` | `standard` | `agent` | | Can read, count, preview, quote | Yes, within its scopes | Yes, for the tools in `allowed_tools` | | Can spend | Never on its own. A person must confirm | Within its per-order cap and monthly budget | | `admin` scope | Optional | Never | An agent key is an API key with a budget, created by a human with these members. | Member | Meaning | | --- | --- | | `label` | What the key is for | | `mode` | `live` or `test` | | `scopes` | The scopes it holds. An agent key never takes `admin` | | `allowed_tools` | Required. The MCP tools, and the REST operations behind them, that the key may call | | `allowed_connection_ids` | The CRM connections a live push may use | | `monthly_budget_credits` | Credits the key may spend a month. The default is 1,000 (to confirm) | | `per_order_max_credits` | Required. The most one order may cost, no more than the monthly budget | | `expires_at` | When the key stops working | | `webhook_url` | An https endpoint for the key's notices. Register an endpoint at the same URL so the notice can be signed | The app shows a live spend meter, and a key can be revoked at any time. An agent key may call only the tools it lists. Any other operation, such as suppression uploads, webhooks, deleting a saved audience or the event log, is for a person. See [Spending safely](https://emailmovers.com/mydatahub/docs/spending-safely.md). ## Scopes Scopes are fine-grained, and OAuth tokens and API keys carry them the same way. | Scope | What it grants | Spends credits | | --- | --- | --- | | `account:read` | Read the balance, plan, prices, rate limits, usage and key budgets. | no | | `account:write` | Set the business name and confirm that it is a UK business (PATCH /v1/account). A signed-in owner only; admin covers it too. | no | | `counts:read` | Run counts and breakdowns with the account's exact figures and New to you. | no | | `previews:read` | Draw masked samples. | no | | `audiences:read` | Read saved audiences. | no | | `audiences:write` | Save, update, refresh and delete audiences. | no | | `quotes:read` | Create and read machine quotes with the account's balance. | no | | `orders:read` | Read orders, compliance receipts and CRM pushes. | no | | `orders:write` | Spend credits: create and cancel orders. | yes | | `records:read` | Fetch and download delivered records under licence. | no | | `enrich:write` | Spend credits: enrich a known contact, charged only on a confident match. | yes | | `suppressions:read` | Read suppression lists and check records before a send. | no | | `suppressions:write` | Upload suppression lists and report objections. | no | | `pushes:write` | Connect CRMs, check overlap and push delivered records. | no | | `webhooks:write` | Manage webhook endpoints and read the event log. | no | | `samples:request` | Ask for a sample of a counted audience, sent by email to a confirmed work address. Never returns records. | no | | `admin` | Manage API keys, agent keys and the plan, including cancellation. Keys are made and changed by signed-in people only, and an agent key never holds admin. | no | The first 401 challenge asks only for `counts:read previews:read audiences:read`. A later spend answers `403 insufficient-scope` with `required_scope` set to `orders:write` and a `WWW-Authenticate` step-up challenge, so a consent screen names the spend. See [insufficient-scope](https://emailmovers.com/mydatahub/docs/errors/insufficient-scope.md). ## OAuth 2.1 `https://mydatahub.emailmovers.com/id` is the one issuer. At launch it signs people in to the app. Sign-in from REST and MCP clients, so that an assistant connects with no key to copy, is planned for after launch; until then they use an API key. The list below describes the issuer as planned for all three. - Authorization code with PKCE, and only the `S256` method. - RFC 8414 authorization server metadata, and rotating refresh tokens. - JWT access tokens that last 60 minutes, with `aud` set to the exact resource (RFC 8707). - RFC 9728 protected resource metadata for each resource on mydatahub.emailmovers.com, at `/.well-known/oauth-protected-resource/v1` and `/.well-known/oauth-protected-resource/mcp`, naming `https://mydatahub.emailmovers.com/id` as the only authorization server. After launch, client registration follows the [2026-07-28 MCP order](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/client-registration): pre-registered redirect URIs for claude.ai, ChatGPT and Cursor first, then Client ID Metadata Documents, then Dynamic Client Registration for older clients. Loopback redirects match any port, for Claude Code. Every OAuth endpoint is built to answer within 2 seconds. ## Every call is attributable Each order records who called it: the OAuth client id or the key id, the user, the User-Agent, the client certificate fingerprint when one is presented (ChatGPT's mutual TLS), and any Web Bot Auth signature. It appears in the audit trail and on invoices. See [Quotes and orders](https://emailmovers.com/mydatahub/docs/quotes-and-orders.md). ## Anonymous use With no token you can run counts, previews and quotes within the anonymous limits. These are 60 counts an hour and 600 a day per IP, and up to 6 filters in one audience. Single-company counts, free text title search and a radius under 5 miles need a free account, which comes with 100 credits and no card. See [Rate limits](https://emailmovers.com/mydatahub/docs/rate-limits.md). ## Today's credentials The `X-Api-Key` header and `POST /api/RunExtract` belong to the current product. The plan is for them to keep working for 12 months from the v2 launch and then return 410. The v2 API does not accept the header or serve that route yet (to confirm). Move to `Authorization: Bearer` with an `mdh_` key. See the [migration guide](https://emailmovers.com/mydatahub/docs/migrate.md). ## Next steps - Count with a key in the [Quickstart for developers](https://emailmovers.com/mydatahub/docs/quickstart/developers.md). - Create an agent key and read about budgets in [Spending safely](https://emailmovers.com/mydatahub/docs/spending-safely.md). - Connect a client in the [Quickstart for agents](https://emailmovers.com/mydatahub/docs/quickstart/agents.md). --- # The data licence agreement must be accepted > licence-acceptance-required: HTTP 403. Nothing was charged. A person accepts the version in force (the account's agreements.dlsa): by ticking the box on the quote or at approval_url, where they can also approve this spend, or, for an agent key that orders or enriches on its own, on the key. Then the same request is sent again. Source: https://emailmovers.com/mydatahub/docs/errors/licence-acceptance-required `licence-acceptance-required` is returned with HTTP status 403. Its type URI is `https://emailmovers.com/mydatahub/docs/errors/licence-acceptance-required`. ## What it means A person approved the spend, but an order or an enrichment needs the Data Licence and Sharing Agreement accepted at the version in force, and it was not, or an earlier version was. In production it also answers while the agreement is not published yet. Nothing was charged. The problem carries approval_url, where a person approves the spend and ticks the box. In the app a person ticks it on the quote; the version is the account's agreements.dlsa.version. An agent key that orders or enriches on its own (create_order or enrich_contact, with a budget and a per-order cap above 0) carries the acceptance made when the key was made or changed: PATCH /v1/api-keys/{id} with licence_accepted_version renews it. Then the same request is sent again. ## What to do Nothing was charged. A person accepts the version in force (the account's agreements.dlsa): by ticking the box on the quote or at approval_url, where they can also approve this spend, or, for an agent key that orders or enriches on its own, on the key. Then the same request is sent again. ## Example ```json { "type": "https://emailmovers.com/mydatahub/docs/errors/licence-acceptance-required", "title": "The data licence agreement must be accepted", "status": 403, "code": "licence-acceptance-required", "detail": "This order needs the Data Licence and Sharing Agreement, version 1.0 (https://emailmovers.com/data-licence/) accepted. Tick the box on the quote to accept it, then place the order. Nothing was charged.", "next_step": "Nothing was charged. A person accepts the version in force (the account's agreements.dlsa): by ticking the box on the quote or at approval_url, where they can also approve this spend, or, for an agent key that orders or enriches on its own, on the key. Then the same request is sent again." } ``` ## See also - [Quotes and orders](https://emailmovers.com/mydatahub/docs/quotes-and-orders.md) - [Spending safely](https://emailmovers.com/mydatahub/docs/spending-safely.md) All problem types are listed on [Errors](https://emailmovers.com/mydatahub/docs/errors.md). --- # The data processing agreement must be accepted > dpa-acceptance-required: HTTP 403. Nothing was uploaded. A person who owns the account accepts the Article 28 data processing agreement at the version in force (the account's agreements.dpa): in the app's Settings, with the box on the app's first upload, or with POST /v1/account/agreements. Then the same list is uploaded again. Source: https://emailmovers.com/mydatahub/docs/errors/dpa-acceptance-required `dpa-acceptance-required` is returned with HTTP status 403. Its type URI is `https://emailmovers.com/mydatahub/docs/errors/dpa-acceptance-required`. ## What it means A suppression list is processed under the Article 28 data processing agreement, so the organisation accepts the version in force before its first upload. It has not, or it accepted an earlier version. Nothing was uploaded. A person who owns the account accepts it in the app's Settings, or with POST /v1/account/agreements, which takes a signed-in person and never an API key. The app's first upload asks with a box of its own. Then the same list is uploaded again. ## What to do Nothing was uploaded. A person who owns the account accepts the Article 28 data processing agreement at the version in force (the account's agreements.dpa): in the app's Settings, with the box on the app's first upload, or with POST /v1/account/agreements. Then the same list is uploaded again. ## Example ```json { "type": "https://emailmovers.com/mydatahub/docs/errors/dpa-acceptance-required", "title": "The data processing agreement must be accepted", "status": 403, "code": "dpa-acceptance-required", "detail": "This organisation has not accepted the Article 28 data processing agreement, version 1.0 (https://emailmovers.com/dpa/), which comes before a suppression list is processed. A person who owns the account accepts it in the app's Settings, or with POST /v1/account/agreements. Nothing was uploaded.", "next_step": "Nothing was uploaded. A person who owns the account accepts the Article 28 data processing agreement at the version in force (the account's agreements.dpa): in the app's Settings, with the box on the app's first upload, or with POST /v1/account/agreements. Then the same list is uploaded again." } ``` ## See also - [Suppressions](https://emailmovers.com/mydatahub/docs/suppressions.md) - [Authentication](https://emailmovers.com/mydatahub/docs/authentication.md) All problem types are listed on [Errors](https://emailmovers.com/mydatahub/docs/errors.md). --- # The agent key budget does not cover this spend > agent-budget-exceeded: HTTP 403. Keep the machine quote_id and ask the key owner to approve it at approval_url, or top up at top_up_url. Source: https://emailmovers.com/mydatahub/docs/errors/agent-budget-exceeded `agent-budget-exceeded` is returned with HTTP status 403. Its type URI is `https://emailmovers.com/mydatahub/docs/errors/agent-budget-exceeded`. ## What it means An agent key's per-order cap or monthly budget does not cover the spend. No order was created. The key owner is told only when the key has a webhook_url and an endpoint is registered at that exact URL, so the notice can be signed. The limits are checked before the balance, so an agent over its cap sees this even when the balance is also short. ## What to do Keep the machine quote_id and ask the key owner to approve it at approval_url, or top up at top_up_url. ## Members it adds Beyond `type`, `title`, `status`, `detail`, `code` and `next_step`, this problem carries `budget`, `per_order_max_credits`, `quote_id`, `credits_required`, `approval_url` and `top_up_url`. They are published in the `AgentBudgetProblem` schema of the OpenAPI document. ## Example ```json { "type": "https://emailmovers.com/mydatahub/docs/errors/agent-budget-exceeded", "title": "The agent key budget does not cover this spend", "status": 403, "code": "agent-budget-exceeded", "detail": "The order needs 5,000 credits and the key has 800 left this month.", "next_step": "Keep the machine quote_id and ask the key owner to approve it at approval_url, or top up at top_up_url.", "budget": { "monthly_credits": 1000, "spent": 200, "remaining": 800, "resets_at": "2026-10-01T00:00:00Z" }, "per_order_max_credits": 500, "quote_id": "qte_01JB4M2R6T1Y5W8ZAK8Q2V7XN3", "credits_required": 5000, "approval_url": "https://mydatahub.emailmovers.com/quotes/qte_01JB4M2R6T1Y5W8ZAK8Q2V7XN3/approve", "top_up_url": "https://mydatahub.emailmovers.com/billing/top-up" } ``` ## See also - [Spending safely](https://emailmovers.com/mydatahub/docs/spending-safely.md) - [Authentication](https://emailmovers.com/mydatahub/docs/authentication.md) All problem types are listed on [Errors](https://emailmovers.com/mydatahub/docs/errors.md). --- # Not found > not-found: HTTP 404. Check the id; ids are prefixed, such as ord_ for orders and aud_ for audiences. Source: https://emailmovers.com/mydatahub/docs/errors/not-found `not-found` is returned with HTTP status 404. Its type URI is `https://emailmovers.com/mydatahub/docs/errors/not-found`. ## What it means There is no such object for this organisation. Ids are prefixed, such as ord_ for orders and aud_ for audiences, and an object that belongs to another organisation reads as not found. ## What to do Check the id; ids are prefixed, such as ord_ for orders and aud_ for audiences. ## Example ```json { "type": "https://emailmovers.com/mydatahub/docs/errors/not-found", "title": "Not found", "status": 404, "code": "not-found", "detail": "No order ord_01JB4N9S3W2X6Y7Z8A1B2C3D4E.", "next_step": "Check the id; ids are prefixed, such as ord_ for orders and aud_ for audiences." } ``` ## See also - [REST reference](https://emailmovers.com/mydatahub/docs/reference/rest.md) All problem types are listed on [Errors](https://emailmovers.com/mydatahub/docs/errors.md). --- # Method not allowed > method-not-allowed: HTTP 405. Use a method named in the Allow header; every path starts /v1 and /v1/openapi.json lists them. Source: https://emailmovers.com/mydatahub/docs/errors/method-not-allowed `method-not-allowed` is returned with HTTP status 405. Its type URI is `https://emailmovers.com/mydatahub/docs/errors/method-not-allowed`. ## What it means The path exists, but not for the HTTP method you used. The Allow header names the methods that work there, as RFC 9110 asks. Nothing was charged. ## What to do Use a method named in the Allow header; every path starts /v1 and /v1/openapi.json lists them. ## Example ```json { "type": "https://emailmovers.com/mydatahub/docs/errors/method-not-allowed", "title": "Method not allowed", "status": 405, "code": "method-not-allowed", "detail": "POST is not available at /v1/plans; GET is.", "next_step": "Use a method named in the Allow header; every path starts /v1 and /v1/openapi.json lists them." } ``` ## See also - [REST reference](https://emailmovers.com/mydatahub/docs/reference/rest.md) All problem types are listed on [Errors](https://emailmovers.com/mydatahub/docs/errors.md). --- # Idempotency-Key reused with different parameters > idempotency-key-reused: HTTP 409. Use a new Idempotency-Key for a different request; reuse a key only to retry. Source: https://emailmovers.com/mydatahub/docs/errors/idempotency-key-reused `idempotency-key-reused` is returned with HTTP status 409. Its type URI is `https://emailmovers.com/mydatahub/docs/errors/idempotency-key-reused`. ## What it means The Idempotency-Key was used before with different parameters. Reuse a key only to retry the same request. ## What to do Use a new Idempotency-Key for a different request; reuse a key only to retry. ## Example ```json { "type": "https://emailmovers.com/mydatahub/docs/errors/idempotency-key-reused", "title": "Idempotency-Key reused with different parameters", "status": 409, "code": "idempotency-key-reused", "detail": "This Idempotency-Key belongs to a different request.", "next_step": "Use a new Idempotency-Key for a different request; reuse a key only to retry." } ``` ## See also - [Quotes and orders](https://emailmovers.com/mydatahub/docs/quotes-and-orders.md) All problem types are listed on [Errors](https://emailmovers.com/mydatahub/docs/errors.md). --- # The machine quote has expired > quote-expired: HTTP 409. Call quote_order (POST /v1/quotes) again; a machine quote is valid for 24 hours. Source: https://emailmovers.com/mydatahub/docs/errors/quote-expired `quote-expired` is returned with HTTP status 409. Its type URI is `https://emailmovers.com/mydatahub/docs/errors/quote-expired`. ## What it means A machine quote is valid for 24 hours. Quote again, then create the order from the new quote_id. ## What to do Call quote_order (POST /v1/quotes) again; a machine quote is valid for 24 hours. ## Example ```json { "type": "https://emailmovers.com/mydatahub/docs/errors/quote-expired", "title": "The machine quote has expired", "status": 409, "code": "quote-expired", "detail": "Quote qte_01JB4M2R6T1Y5W8ZAK8Q2V7XN3 expired at 2026-09-27T10:14:00Z.", "next_step": "Call quote_order (POST /v1/quotes) again; a machine quote is valid for 24 hours." } ``` ## See also - [Quotes and orders](https://emailmovers.com/mydatahub/docs/quotes-and-orders.md) All problem types are listed on [Errors](https://emailmovers.com/mydatahub/docs/errors.md). --- # The order is not ready > order-not-ready: HTTP 409. Wait for the order.ready webhook, or poll GET /v1/orders/{id} until status is ready. Source: https://emailmovers.com/mydatahub/docs/errors/order-not-ready `order-not-ready` is returned with HTTP status 409. Its type URI is `https://emailmovers.com/mydatahub/docs/errors/order-not-ready`. ## What it means Records, downloads and the receipt wait for the order to reach status ready. The same problem is returned once the licence has ended, with the date. ## What to do Wait for the order.ready webhook, or poll GET /v1/orders/{id} until status is ready. ## Example ```json { "type": "https://emailmovers.com/mydatahub/docs/errors/order-not-ready", "title": "The order is not ready", "status": 409, "code": "order-not-ready", "detail": "Order ord_01JB4N9S3W2X6Y7Z8A1B2C3D4E is building.", "next_step": "Wait for the order.ready webhook, or poll GET /v1/orders/{id} until status is ready." } ``` ## See also - [Quotes and orders](https://emailmovers.com/mydatahub/docs/quotes-and-orders.md) - [Webhooks](https://emailmovers.com/mydatahub/docs/webhooks.md) All problem types are listed on [Errors](https://emailmovers.com/mydatahub/docs/errors.md). --- # Only a queued order can be cancelled > order-not-cancellable: HTTP 409. Records already delivered stay under licence; report bounces for a warranty claim. Source: https://emailmovers.com/mydatahub/docs/errors/order-not-cancellable `order-not-cancellable` is returned with HTTP status 409. Its type URI is `https://emailmovers.com/mydatahub/docs/errors/order-not-cancellable`. ## What it means Only a queued order can be cancelled. Records already delivered stay under licence. ## What to do Records already delivered stay under licence; report bounces for a warranty claim. ## Example ```json { "type": "https://emailmovers.com/mydatahub/docs/errors/order-not-cancellable", "title": "Only a queued order can be cancelled", "status": 409, "code": "order-not-cancellable", "detail": "Order ord_01JB4N9S3W2X6Y7Z8A1B2C3D4E is already ready.", "next_step": "Records already delivered stay under licence; report bounces for a warranty claim." } ``` ## See also - [Quotes and orders](https://emailmovers.com/mydatahub/docs/quotes-and-orders.md) All problem types are listed on [Errors](https://emailmovers.com/mydatahub/docs/errors.md). --- # The request is not valid > validation-failed: HTTP 422. Fix each entry in errors, using did_you_mean where given, and send it again. Source: https://emailmovers.com/mydatahub/docs/errors/validation-failed `validation-failed` is returned with HTTP status 422. Its type URI is `https://emailmovers.com/mydatahub/docs/errors/validation-failed`. ## What it means The request is readable but not valid. errors lists every problem with a JSON pointer, and did_you_mean offers the closest valid values. A company, count, quote or suppression list that the body names but does not exist is reported here, with a pointer to the member. ## What to do Fix each entry in errors, using did_you_mean where given, and send it again. ## Members it adds Beyond `type`, `title`, `status`, `detail`, `code` and `next_step`, this problem carries `errors` and `did_you_mean`. They are published in the `Problem` schema of the OpenAPI document. ## Example ```json { "type": "https://emailmovers.com/mydatahub/docs/errors/validation-failed", "title": "The request is not valid", "status": 422, "code": "validation-failed", "detail": "Unknown field company.employees.", "next_step": "Fix each entry in errors, using did_you_mean where given, and send it again.", "instance": "/v1/counts", "did_you_mean": ["company.employee_band"], "errors": [ { "pointer": "/audience/where/all/0/field", "code": "invalid_value", "message": "Unknown field company.employees.", "did_you_mean": ["company.employee_band"] } ] } ``` ## See also - [The audience definition](https://emailmovers.com/mydatahub/docs/audience.md) All problem types are listed on [Errors](https://emailmovers.com/mydatahub/docs/errors.md). --- # A machine quote is required > quote-required: HTTP 422. Call quote_order first; a machine quote is valid for 24 hours. Source: https://emailmovers.com/mydatahub/docs/errors/quote-required `quote-required` is returned with HTTP status 422. Its type URI is `https://emailmovers.com/mydatahub/docs/errors/quote-required`. ## What it means create_order and POST /v1/orders need a live machine quote_id. It is also returned when a quote was already settled in the other mode, test or live. ## What to do Call quote_order first; a machine quote is valid for 24 hours. ## Example ```json { "type": "https://emailmovers.com/mydatahub/docs/errors/quote-required", "title": "A machine quote is required", "status": 422, "code": "quote-required", "detail": "Call quote_order first; a quote is valid for 24 hours.", "next_step": "Call quote_order first; a machine quote is valid for 24 hours." } ``` ## See also - [Quotes and orders](https://emailmovers.com/mydatahub/docs/quotes-and-orders.md) - [Spending safely](https://emailmovers.com/mydatahub/docs/spending-safely.md) All problem types are listed on [Errors](https://emailmovers.com/mydatahub/docs/errors.md). --- # Errors > The 26 problem types the API returns, each with its HTTP status and what to do next. Source: https://emailmovers.com/mydatahub/docs/errors Every error is an [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457.html) problem with the media type `application/problem+json`. Its `type` is a URI under `https://emailmovers.com/mydatahub/docs/errors/`, and each of the pages below is that URI. Two members go beyond the RFC: `next_step` says what to do, and `did_you_mean` lists the closest valid values. MCP tool errors reuse the same object with `isError` true. The text content leads with the sentence a person acts on, followed by the whole problem as JSON. ## Problem types | Code | HTTP | Title | Next step | | --- | ---: | --- | --- | | [`invalid-request`](https://emailmovers.com/mydatahub/docs/errors/invalid-request.md) | 400 | The request could not be read | Send a JSON body with Content-Type: application/json and check the query string. | | [`idempotency-key-required`](https://emailmovers.com/mydatahub/docs/errors/idempotency-key-required.md) | 400 | An Idempotency-Key header is required | Send an Idempotency-Key header (a V4 UUID is a good choice) and reuse it on every retry. | | [`unsupported-version`](https://emailmovers.com/mydatahub/docs/errors/unsupported-version.md) | 400 | Unsupported MyDataHub-Version | Send a MyDataHub-Version date the API lists, such as 2027-01-15, or leave it out. | | [`authentication-required`](https://emailmovers.com/mydatahub/docs/errors/authentication-required.md) | 401 | Sign in or send an API key | Send Authorization: Bearer with an mdh_ API key. Sign-in through https://mydatahub.emailmovers.com/id is planned. | | [`account-required`](https://emailmovers.com/mydatahub/docs/errors/account-required.md) | 401 | This needs a free account | Create a free account (100 credits, no card) for single company counts, more than 6 filters in one audience, free text title search or a radius under 5 miles. Free and trial accounts still see rounded small counts; exact figures come with a paid plan. | | [`insufficient-credits`](https://emailmovers.com/mydatahub/docs/errors/insufficient-credits.md) | 402 | Not enough credits | Top up at the top_up_url, then retry with the same machine quote_id (or the same enrichment request). | | [`insufficient-scope`](https://emailmovers.com/mydatahub/docs/errors/insufficient-scope.md) | 403 | The token lacks a scope | Ask the user to approve the scope named in required_scope, then retry. | | [`confirmation-required`](https://emailmovers.com/mydatahub/docs/errors/confirmation-required.md) | 403 | A person must confirm this spend | Nothing was charged. Keep the machine quote_id and give the user the approval_url; retry with the confirmation_token it returns, or use an agent key whose limits cover the spend. | | [`business-details-required`](https://emailmovers.com/mydatahub/docs/errors/business-details-required.md) | 403 | Business details are needed before the first order | Nothing was charged. A person who owns the account confirms the business name and that it is a UK business, in the app's Settings or with PATCH /v1/account, then the same machine quote_id is ordered again. | | [`licence-acceptance-required`](https://emailmovers.com/mydatahub/docs/errors/licence-acceptance-required.md) | 403 | The data licence agreement must be accepted | Nothing was charged. A person accepts the version in force (the account's agreements.dlsa): by ticking the box on the quote or at approval_url, where they can also approve this spend, or, for an agent key that orders or enriches on its own, on the key. Then the same request is sent again. | | [`dpa-acceptance-required`](https://emailmovers.com/mydatahub/docs/errors/dpa-acceptance-required.md) | 403 | The data processing agreement must be accepted | Nothing was uploaded. A person who owns the account accepts the Article 28 data processing agreement at the version in force (the account's agreements.dpa): in the app's Settings, with the box on the app's first upload, or with POST /v1/account/agreements. Then the same list is uploaded again. | | [`agent-budget-exceeded`](https://emailmovers.com/mydatahub/docs/errors/agent-budget-exceeded.md) | 403 | The agent key budget does not cover this spend | Keep the machine quote_id and ask the key owner to approve it at approval_url, or top up at top_up_url. | | [`not-found`](https://emailmovers.com/mydatahub/docs/errors/not-found.md) | 404 | Not found | Check the id; ids are prefixed, such as ord_ for orders and aud_ for audiences. | | [`method-not-allowed`](https://emailmovers.com/mydatahub/docs/errors/method-not-allowed.md) | 405 | Method not allowed | Use a method named in the Allow header; every path starts /v1 and /v1/openapi.json lists them. | | [`idempotency-key-reused`](https://emailmovers.com/mydatahub/docs/errors/idempotency-key-reused.md) | 409 | Idempotency-Key reused with different parameters | Use a new Idempotency-Key for a different request; reuse a key only to retry. | | [`quote-expired`](https://emailmovers.com/mydatahub/docs/errors/quote-expired.md) | 409 | The machine quote has expired | Call quote_order (POST /v1/quotes) again; a machine quote is valid for 24 hours. | | [`order-not-ready`](https://emailmovers.com/mydatahub/docs/errors/order-not-ready.md) | 409 | The order is not ready | Wait for the order.ready webhook, or poll GET /v1/orders/{id} until status is ready. | | [`order-not-cancellable`](https://emailmovers.com/mydatahub/docs/errors/order-not-cancellable.md) | 409 | Only a queued order can be cancelled | Records already delivered stay under licence; report bounces for a warranty claim. | | [`validation-failed`](https://emailmovers.com/mydatahub/docs/errors/validation-failed.md) | 422 | The request is not valid | Fix each entry in errors, using did_you_mean where given, and send it again. | | [`quote-required`](https://emailmovers.com/mydatahub/docs/errors/quote-required.md) | 422 | A machine quote is required | Call quote_order first; a machine quote is valid for 24 hours. | | [`dataset-unavailable`](https://emailmovers.com/mydatahub/docs/errors/dataset-unavailable.md) | 422 | The dataset is not served yet | Use uk_contacts; GET /v1/datasets shows when other datasets open. | | [`rate-limited`](https://emailmovers.com/mydatahub/docs/errors/rate-limited.md) | 429 | Too many requests | Wait for the seconds in Retry-After, then retry. | | [`internal-error`](https://emailmovers.com/mydatahub/docs/errors/internal-error.md) | 500 | Something went wrong on our side | Nothing was charged. Retry with the same Idempotency-Key; if it persists, quote the instance to support. | | [`not-implemented`](https://emailmovers.com/mydatahub/docs/errors/not-implemented.md) | 501 | Not implemented yet | Use the operations that answer today; /v1/openapi.json lists every operation and how it is declared. | | [`service-unavailable`](https://emailmovers.com/mydatahub/docs/errors/service-unavailable.md) | 503 | The service is briefly unavailable | Wait for the seconds in Retry-After, then retry with the same Idempotency-Key. The status page shows incidents. | | [`tps-screen-expired`](https://emailmovers.com/mydatahub/docs/errors/tps-screen-expired.md) | 503 | The TPS and CTPS screen is more than 28 days old | Nothing was charged. Email-only audiences carry on, with a field set without telephone and no phone. filter or breakdown. Telephone audiences return once a new dataset version is published; GET /v1/health shows the screen's age. | ## Reading a problem ```json { "type": "https://emailmovers.com/mydatahub/docs/errors/validation-failed", "title": "The request is not valid", "status": 422, "detail": "Unknown field company.employees.", "instance": "/v1/counts", "code": "validation-failed", "next_step": "Fix each entry in errors, using did_you_mean where given, and send it again.", "did_you_mean": ["company.employee_band"], "errors": [ { "pointer": "/audience/where/all/0/field", "code": "invalid_value", "message": "Unknown field company.employees.", "did_you_mean": ["company.employee_band"] } ] } ``` Branch on `code`, never on `detail` or `title`, which are for people and may be reworded. A problem that spends nothing says so in `next_step`. A 4xx response never charges credits, and an order retried with the same `Idempotency-Key` is never charged twice. Every response, errors included, carries the `RateLimit` and `RateLimit-Policy` headers. A 429 and a 503 also carry `Retry-After`. See [Rate limits](https://emailmovers.com/mydatahub/docs/rate-limits.md). --- # The dataset is not served yet > dataset-unavailable: HTTP 422. Use uk_contacts; GET /v1/datasets shows when other datasets open. Source: https://emailmovers.com/mydatahub/docs/errors/dataset-unavailable `dataset-unavailable` is returned with HTTP status 422. Its type URI is `https://emailmovers.com/mydatahub/docs/errors/dataset-unavailable`. ## What it means The dataset is listed but not served yet. Version 2.0 serves uk_contacts only. ## What to do Use uk_contacts; GET /v1/datasets shows when other datasets open. ## Example ```json { "type": "https://emailmovers.com/mydatahub/docs/errors/dataset-unavailable", "title": "The dataset is not served yet", "status": 422, "code": "dataset-unavailable", "detail": "ie_contacts is not served yet.", "next_step": "Use uk_contacts; GET /v1/datasets shows when other datasets open." } ``` ## See also - [Versions and reproducibility](https://emailmovers.com/mydatahub/docs/versions.md) All problem types are listed on [Errors](https://emailmovers.com/mydatahub/docs/errors.md). --- # Too many requests > rate-limited: HTTP 429. Wait for the seconds in Retry-After, then retry. Source: https://emailmovers.com/mydatahub/docs/errors/rate-limited `rate-limited` is returned with HTTP status 429. Its type URI is `https://emailmovers.com/mydatahub/docs/errors/rate-limited`. ## What it means You sent more requests than your tier allows. Wait for the seconds in Retry-After, then retry. ## What to do Wait for the seconds in Retry-After, then retry. ## Members it adds Beyond `type`, `title`, `status`, `detail`, `code` and `next_step`, this problem carries `retry_after_seconds`. They are published in the `Problem` schema of the OpenAPI document. ## Example ```json { "type": "https://emailmovers.com/mydatahub/docs/errors/rate-limited", "title": "Too many requests", "status": 429, "code": "rate-limited", "detail": "Anonymous callers may run 60 counts an hour.", "next_step": "Wait for the seconds in Retry-After, then retry.", "retry_after_seconds": 42 } ``` ## See also - [Rate limits](https://emailmovers.com/mydatahub/docs/rate-limits.md) All problem types are listed on [Errors](https://emailmovers.com/mydatahub/docs/errors.md). --- # Something went wrong on our side > internal-error: HTTP 500. Nothing was charged. Retry with the same Idempotency-Key; if it persists, quote the instance to support. Source: https://emailmovers.com/mydatahub/docs/errors/internal-error `internal-error` is returned with HTTP status 500. Its type URI is `https://emailmovers.com/mydatahub/docs/errors/internal-error`. ## What it means Something failed on our side. For an operation that moves no credits nothing was charged. For an order, a cancellation or an enrichment, check GET /v1/orders or GET /v1/account before retrying. A retry with the same Idempotency-Key never charges twice. ## What to do Nothing was charged. Retry with the same Idempotency-Key; if it persists, quote the instance to support. ## Example ```json { "type": "https://emailmovers.com/mydatahub/docs/errors/internal-error", "title": "Something went wrong on our side", "status": 500, "code": "internal-error", "detail": "The request failed on our side.", "next_step": "Nothing was charged. Retry with the same Idempotency-Key; if it persists, quote the instance to support." } ``` ## See also - [Quotes and orders](https://emailmovers.com/mydatahub/docs/quotes-and-orders.md) All problem types are listed on [Errors](https://emailmovers.com/mydatahub/docs/errors.md). --- # Not implemented yet > not-implemented: HTTP 501. Use the operations that answer today; /v1/openapi.json lists every operation and how it is declared. Source: https://emailmovers.com/mydatahub/docs/errors/not-implemented `not-implemented` is returned with HTTP status 501. Its type URI is `https://emailmovers.com/mydatahub/docs/errors/not-implemented`. ## What it means The operation is in the contract but has no handler yet, so it answers 501 and does nothing. Nothing was charged. It is kept so that an operation added to the contract before it is built answers with a problem and not an empty reply. No operation in the current build answers it, so you should not see it. If you do, quote the instance to support. ## What to do Use the operations that answer today; /v1/openapi.json lists every operation and how it is declared. ## Example ```json { "type": "https://emailmovers.com/mydatahub/docs/errors/not-implemented", "title": "Not implemented yet", "status": 501, "code": "not-implemented", "detail": "getPlans is not built yet in this phase 1 build.", "next_step": "Use the operations that answer today; /v1/openapi.json lists every operation and how it is declared." } ``` ## See also - [REST reference](https://emailmovers.com/mydatahub/docs/reference/rest.md) All problem types are listed on [Errors](https://emailmovers.com/mydatahub/docs/errors.md). --- # The service is briefly unavailable > service-unavailable: HTTP 503. Wait for the seconds in Retry-After, then retry with the same Idempotency-Key. The status page shows incidents. Source: https://emailmovers.com/mydatahub/docs/errors/service-unavailable `service-unavailable` is returned with HTTP status 503. Its type URI is `https://emailmovers.com/mydatahub/docs/errors/service-unavailable`. ## What it means The service is briefly unavailable. Wait for the seconds in Retry-After, then retry with the same Idempotency-Key. ## What to do Wait for the seconds in Retry-After, then retry with the same Idempotency-Key. The status page shows incidents. ## Members it adds Beyond `type`, `title`, `status`, `detail`, `code` and `next_step`, this problem carries `retry_after_seconds`. They are published in the `Problem` schema of the OpenAPI document. ## Example ```json { "type": "https://emailmovers.com/mydatahub/docs/errors/service-unavailable", "title": "The service is briefly unavailable", "status": 503, "code": "service-unavailable", "detail": "The service is briefly unavailable.", "next_step": "Wait for the seconds in Retry-After, then retry with the same Idempotency-Key. The status page shows incidents.", "retry_after_seconds": 30 } ``` ## See also - [Rate limits](https://emailmovers.com/mydatahub/docs/rate-limits.md) All problem types are listed on [Errors](https://emailmovers.com/mydatahub/docs/errors.md). --- # The TPS and CTPS screen is more than 28 days old > tps-screen-expired: HTTP 503. Nothing was charged. Email-only audiences carry on, with a field set without telephone and no phone. filter or breakdown. Telephone audiences return once a new dataset version is published; GET /v1/health shows the screen's age. Source: https://emailmovers.com/mydatahub/docs/errors/tps-screen-expired `tps-screen-expired` is returned with HTTP status 503. Its type URI is `https://emailmovers.com/mydatahub/docs/errors/tps-screen-expired`. ## What it means Every telephone number is screened against the TPS and CTPS, and a screen is valid for 28 days. The screen of the dataset version this request runs on is more than 28 days old, so a count, sample, quote or order with telephone in play is refused rather than served with clear numbers whose screen has run out. Telephone is in play when the field set delivers telephone numbers, or the audience filters or breaks down on a phone. field. It carries no Retry-After: telephone audiences return when a new dataset version is published, not after a set wait. GET /v1/health shows the current version's screen date, its age and whether it is ok, warning (from 22 days) or expired. ## What to do Nothing was charged. Email-only audiences carry on, with a field set without telephone and no phone. filter or breakdown. Telephone audiences return once a new dataset version is published; GET /v1/health shows the screen's age. ## Example ```json { "type": "https://emailmovers.com/mydatahub/docs/errors/tps-screen-expired", "title": "The TPS and CTPS screen is more than 28 days old", "status": 503, "code": "tps-screen-expired", "detail": "The TPS and CTPS screen of this dataset version is more than 28 days old: telephone audiences return once a new version is published.", "next_step": "Nothing was charged. Email-only audiences carry on, with a field set without telephone and no phone. filter or breakdown. Telephone audiences return once a new dataset version is published; GET /v1/health shows the screen's age." } ``` ## See also - [Versions and reproducibility](https://emailmovers.com/mydatahub/docs/versions.md) - [Quotes and orders](https://emailmovers.com/mydatahub/docs/quotes-and-orders.md) All problem types are listed on [Errors](https://emailmovers.com/mydatahub/docs/errors.md). --- # Rate limits > Anonymous callers get 60 counts an hour and 600 a day. Every response reports your remaining quota in the IETF RateLimit headers, and a 429 says how long to wait. Source: https://emailmovers.com/mydatahub/docs/rate-limits Updated: 17 October 2026 Limits depend on who you are, and every response tells you where you stand. Anonymous callers get 60 counts an hour and 600 a day per IP. Free and paid accounts get more, per organisation. The limits protect the counts budget, because a count runs a real query and an order spends credits. > **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. ## Limits by tier | Tier | Counts | Other limits | Status | | --- | --- | --- | --- | | anonymous | 60 an hour and 600 a day per IP and fingerprint | Challenge after 20; at most 6 filters an audience; no single company counts, free text title search or radius under 5 miles; counts under 100 and breakdown cells rounded to the nearest 5 | confirmed | | free | 60 a minute and 1,000 a day per organisation | Counts under 100 and breakdown cells rounded to the nearest 5 | to confirm | | paid | Published per plan | Exact counts | confirmed | The anonymous counts figures are confirmed. The free account figures are to confirm, and paid limits are published per plan. Every request, whatever it does, is also limited. These request limits are the current build's own and are to confirm. | Tier | Counts | Any request | | --- | --- | --- | | Anonymous, per IP | 60 an hour, 600 a day | 120 a minute | | Free account, per organisation | 60 a minute, 1,000 a day | 300 a minute | | Paid, per organisation | 600 a minute | 1,200 a minute | > **To confirm.** A managed challenge after 20 anonymous counts (Cloudflare Turnstile) is an edge control planned for the hosted service. It is not part of the API itself, and the anonymous bucket today is the IP address alone, without a client fingerprint. ## The headers Every response carries the [IETF `RateLimit` and `RateLimit-Policy` fields](https://datatracker.ietf.org/doc/html/draft-ietf-httpapi-ratelimit-headers), errors included. A 429 and a 503 also carry `Retry-After`. | Header | Example | Meaning | | --- | --- | --- | | `RateLimit-Policy` | `"counts-hourly";q=60;w=3600, "counts-daily";q=600;w=86400, "requests";q=120;w=60` | One item for each window that applies to you: its name, the quota `q` and the window `w` in seconds | | `RateLimit` | `"counts-hourly";r=59;t=3522, "counts-daily";r=599;t=50322, "requests";r=119;t=42` | One item for each of those windows: the quota remaining `r` and the seconds until it resets `t` | | `Retry-After` | `42` | Seconds to wait before retrying, on 429 and 503 | Each list holds one item for every window the request is counted against, separated by commas, so read the item by its name. The names are `counts-hourly`, `counts-daily`, `counts-per-minute` and `requests`. A call that is not counted against the counts budget carries only the `requests` item. A 429 returns the [rate-limited](https://emailmovers.com/mydatahub/docs/errors/rate-limited.md) problem, with `retry_after_seconds` repeating the header. ## What uses the counts budget An operation spends from the counts budget when it asks the engine about data: counting, previewing and quoting, and every call that looks up a record, whether or not it finds one. That includes suppression checks, enrichment, objections, suppression uploads and CRM overlap checks. | Call | Extra charge | | --- | --- | | Preview with `shuffle` true | One more unit, and it is logged | | `POST /v1/suppressions/check` | One more unit for each further 100 ids | | `POST /v1/contacts/enrich` with no match | A key may make 100 a day. A match costs a credit, which is its own brake | | `POST /v1/objections` with scope `emailmovers` | One unit, and at most 200 a day for an organisation | The catalogue lookups are on purpose outside the counts budget: filter values, title expansion and location resolution. They answer from the catalogue, and a type-ahead calls them on every keystroke. They are still limited by the request limit. ## Anonymous controls Besides the rate limits, an anonymous caller is limited to 6 filters in one audience. The premium filter, `contact.premium` in `[true]`, is not counted, since the app and a compiled brief add it themselves. Single-company counts, free text title search and a radius under 5 miles need a free account, and the answer is [account-required](https://emailmovers.com/mydatahub/docs/errors/account-required.md). Counts under 100 and every breakdown cell are rounded to the nearest 5. See [Counts and breakdowns](https://emailmovers.com/mydatahub/docs/counts.md). Behind a proxy, the client address is read from the right of `X-Forwarded-For`, never from the left, which the client controls. ## Retrying well 1. On 429, wait for `Retry-After`, then retry. Do not retry sooner. 2. On 503 or 500, wait for `Retry-After` if present, otherwise back off with jitter, and retry with the same `Idempotency-Key`. 3. Read `RateLimit` before a burst, and spread requests across the window. 4. Cache what you can. A count by id answers `304` to `If-None-Match` without running the engine, and the catalogue, plans and datasets carry an `ETag`. ## Next steps - See the status codes of every operation in the [REST reference](https://emailmovers.com/mydatahub/docs/reference/rest.md). - Read [Errors](https://emailmovers.com/mydatahub/docs/errors.md) for `rate-limited` and `service-unavailable`. --- # 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, 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(); 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). --- # Reference > The REST API, the MCP tools and the webhook events, generated from the contracts: 65 operations and 34 tools. Source: https://emailmovers.com/mydatahub/docs/reference The reference is generated from the files the contracts package publishes, so it cannot drift from the API. The tests fail if a page here no longer matches them. > **Not yet live.** MyDataHub v2 is built and tested locally and is not yet live. The v2 hosts open at launch, date to confirm. ## Pages - [REST reference](https://emailmovers.com/mydatahub/docs/reference/rest.md): 65 operations at `https://mydatahub.emailmovers.com/v1`, one page each. - [MCP tool reference](https://emailmovers.com/mydatahub/docs/reference/mcp.md): the 34 tools of `https://mydatahub.emailmovers.com/mcp`, one page each. - [Webhook events](https://emailmovers.com/mydatahub/docs/reference/webhooks.md): the event types and their payloads. - [Errors](https://emailmovers.com/mydatahub/docs/errors.md): every problem type, one page each. ## Schema files The same documents the pages are generated from, as files for code generators and agents. | File | Path | | --- | --- | | [OpenAPI 3.1 document (JSON)](https://emailmovers.com/mydatahub/docs/reference/openapi.json) | `/reference/openapi.json` | | [OpenAPI 3.1 document (YAML)](https://emailmovers.com/mydatahub/docs/reference/openapi.yaml) | `/reference/openapi.yaml` | | [Audience JSON Schema (mydatahub.audience/1)](https://emailmovers.com/mydatahub/docs/reference/audience.schema.json) | `/reference/audience.schema.json` | | [MCP tools/list result](https://emailmovers.com/mydatahub/docs/reference/mcp-tools.json) | `/reference/mcp-tools.json` | The live OpenAPI document is also served at `GET /v1/openapi.json`. --- # REST reference > 65 operations of the MyDataHub REST API, generated from the OpenAPI 3.1 document. Source: https://emailmovers.com/mydatahub/docs/reference/rest 65 operations in 15 groups, all under `https://mydatahub.emailmovers.com/v1`. Each page is generated from the OpenAPI 3.1 document, so a member, scope or cost on a page is the one the API is tested against. > **Not yet live.** MyDataHub v2 is built and tested locally and is not yet live. The v2 hosts open at launch, date to confirm. Send `MyDataHub-Version` with a date to pin behaviour, and `Idempotency-Key` on any request that creates or charges. See [Versions and reproducibility](https://emailmovers.com/mydatahub/docs/versions.md) and [Quotes and orders](https://emailmovers.com/mydatahub/docs/quotes-and-orders.md). Lists return `{data, has_more, next_cursor}`. ## Catalogue Free and public: plans, datasets, filter fields and values, field sets, title expansion, location resolution and compiling a brief. | Operation | Endpoint | Cost | | --- | --- | --- | | [Get plans and prices](https://emailmovers.com/mydatahub/docs/reference/rest/get-plans.md) | `GET /v1/plans` | Free (0 credits) | | [List datasets](https://emailmovers.com/mydatahub/docs/reference/rest/list-datasets.md) | `GET /v1/datasets` | Free (0 credits) | | [Get a dataset version changelog](https://emailmovers.com/mydatahub/docs/reference/rest/get-dataset-version.md) | `GET /v1/datasets/{dataset}/versions/{version}` | Free (0 credits) | | [List filter fields](https://emailmovers.com/mydatahub/docs/reference/rest/list-filters.md) | `GET /v1/catalogue/filters` | Free (0 credits) | | [Search filter values](https://emailmovers.com/mydatahub/docs/reference/rest/search-filter-values.md) | `GET /v1/catalogue/filters/{dimension}/values` | Free (0 credits) | | [List field sets](https://emailmovers.com/mydatahub/docs/reference/rest/list-field-sets.md) | `GET /v1/field-sets` | Free (0 credits) | | [Expand job titles](https://emailmovers.com/mydatahub/docs/reference/rest/expand-job-titles.md) | `POST /v1/job-titles/expand` | Free (0 credits) | | [Resolve a location](https://emailmovers.com/mydatahub/docs/reference/rest/resolve-location.md) | `POST /v1/locations/resolve` | Free (0 credits) | | [Compile a brief](https://emailmovers.com/mydatahub/docs/reference/rest/compile-audience.md) | `POST /v1/audiences/compile` | Free (0 credits) | ## Counts Free and public: counts with breakdowns, and masked samples (previews). | Operation | Endpoint | Cost | | --- | --- | --- | | [Count an audience](https://emailmovers.com/mydatahub/docs/reference/rest/create-count.md) | `POST /v1/counts` | Free (0 credits) | | [Get a count](https://emailmovers.com/mydatahub/docs/reference/rest/get-count.md) | `GET /v1/counts/{id}` | Free (0 credits) | | [Preview sample records](https://emailmovers.com/mydatahub/docs/reference/rest/create-preview.md) | `POST /v1/previews` | Free (0 credits) | ## Samples A sample of a counted audience, sent only after a person confirms a work email address, and downloaded once. | Operation | Endpoint | Cost | | --- | --- | --- | | [Ask for a sample of a count](https://emailmovers.com/mydatahub/docs/reference/rest/create-sample-request.md) | `POST /v1/sample-requests` | Free (0 credits) | | [Send the confirmation link for a sample](https://emailmovers.com/mydatahub/docs/reference/rest/create-sample-request-email.md) | `POST /v1/sample-requests/{id}/email` | Free (0 credits) | | [Get a sample request](https://emailmovers.com/mydatahub/docs/reference/rest/get-sample-request.md) | `GET /v1/sample-requests/{id}` | Free (0 credits) | | [Confirm a sample and get its download link](https://emailmovers.com/mydatahub/docs/reference/rest/create-sample-download.md) | `POST /v1/sample-downloads` | Free (0 credits) | ## Audiences Saved audiences shared across the organisation. | Operation | Endpoint | Cost | | --- | --- | --- | | [List saved audiences](https://emailmovers.com/mydatahub/docs/reference/rest/list-audiences.md) | `GET /v1/audiences` | Free (0 credits) | | [Save an audience](https://emailmovers.com/mydatahub/docs/reference/rest/create-audience.md) | `POST /v1/audiences` | Free (0 credits) | | [Get a saved audience](https://emailmovers.com/mydatahub/docs/reference/rest/get-audience.md) | `GET /v1/audiences/{id}` | Free (0 credits) | | [Update a saved audience](https://emailmovers.com/mydatahub/docs/reference/rest/update-audience.md) | `PATCH /v1/audiences/{id}` | Free (0 credits) | | [Delete a saved audience](https://emailmovers.com/mydatahub/docs/reference/rest/delete-audience.md) | `DELETE /v1/audiences/{id}` | Free (0 credits) | | [Refresh a saved audience](https://emailmovers.com/mydatahub/docs/reference/rest/refresh-audience.md) | `POST /v1/audiences/{id}/refresh` | Free (0 credits) | ## Quotes Machine quotes (24 hours, free and public) and human sales quotes (14 days). | Operation | Endpoint | Cost | | --- | --- | --- | | [Quote an order](https://emailmovers.com/mydatahub/docs/reference/rest/create-quote.md) | `POST /v1/quotes` | Free (0 credits) | | [Get a quote](https://emailmovers.com/mydatahub/docs/reference/rest/get-quote.md) | `GET /v1/quotes/{id}` | Free (0 credits) | ## Orders Spend credits against a machine quote; status, cancellation and the signed compliance receipt. | Operation | Endpoint | Cost | | --- | --- | --- | | [List orders](https://emailmovers.com/mydatahub/docs/reference/rest/list-orders.md) | `GET /v1/orders` | Free (0 credits) | | [Create an order (spends credits)](https://emailmovers.com/mydatahub/docs/reference/rest/create-order.md) | `POST /v1/orders` | 1 credit per complete UK record (uk_contacts). Suppressed, duplicate and already held records cost 0. | | [Get an order](https://emailmovers.com/mydatahub/docs/reference/rest/get-order.md) | `GET /v1/orders/{id}` | Free (0 credits) | | [Cancel a queued order](https://emailmovers.com/mydatahub/docs/reference/rest/cancel-order.md) | `POST /v1/orders/{id}/cancel` | Free (0 credits) | | [Get the compliance receipt](https://emailmovers.com/mydatahub/docs/reference/rest/get-compliance-receipt.md) | `GET /v1/orders/{id}/receipt` | Free (0 credits) | ## Records Delivered records under licence, as pages or a signed download. Free. | Operation | Endpoint | Cost | | --- | --- | --- | | [Fetch order records](https://emailmovers.com/mydatahub/docs/reference/rest/list-order-records.md) | `GET /v1/orders/{id}/records` | Free (0 credits) | | [Download order records](https://emailmovers.com/mydatahub/docs/reference/rest/download-order.md) | `GET /v1/orders/{id}/download` | Free (0 credits) | ## Lookups Company lookup (free) and contact enrichment (1 credit on a confident match). | Operation | Endpoint | Cost | | --- | --- | --- | | [Look up a company](https://emailmovers.com/mydatahub/docs/reference/rest/lookup-company.md) | `POST /v1/companies/lookup` | Free (0 credits) | | [Enrich a contact (spends credits)](https://emailmovers.com/mydatahub/docs/reference/rest/enrich-contact.md) | `POST /v1/contacts/enrich` | 1 credit on a confident match; 0 on no match or when already licensed. | ## Suppressions Suppression lists, checks before a send, and objections. | Operation | Endpoint | Cost | | --- | --- | --- | | [List suppression lists](https://emailmovers.com/mydatahub/docs/reference/rest/list-suppressions.md) | `GET /v1/suppressions` | Free (0 credits) | | [Upload a suppression list](https://emailmovers.com/mydatahub/docs/reference/rest/create-suppression-list.md) | `POST /v1/suppressions` | Free (0 credits) | | [Check suppression](https://emailmovers.com/mydatahub/docs/reference/rest/check-suppression.md) | `POST /v1/suppressions/check` | Free (0 credits) | | [Report an objection](https://emailmovers.com/mydatahub/docs/reference/rest/report-objection.md) | `POST /v1/objections` | Free (0 credits) | ## Compliance Public: the trust document, the LIA and compliance receipt verification. | Operation | Endpoint | Cost | | --- | --- | --- | | [Get the trust document](https://emailmovers.com/mydatahub/docs/reference/rest/get-trust.md) | `GET /v1/trust` | Free (0 credits) | | [Get the legitimate interests assessment](https://emailmovers.com/mydatahub/docs/reference/rest/get-lia.md) | `GET /v1/compliance/lia` | Free (0 credits) | | [Verify a compliance receipt](https://emailmovers.com/mydatahub/docs/reference/rest/verify-compliance-receipt.md) | `POST /v1/compliance/receipts/verify` | Free (0 credits) | ## Account Balance, plan, usage, credits bought by card, and ending a rolling plan. | Operation | Endpoint | Cost | | --- | --- | --- | | [Get the account](https://emailmovers.com/mydatahub/docs/reference/rest/get-account.md) | `GET /v1/account` | Free (0 credits) | | [Confirm the business details](https://emailmovers.com/mydatahub/docs/reference/rest/update-account.md) | `PATCH /v1/account` | Free (0 credits) | | [Accept the data processing agreement](https://emailmovers.com/mydatahub/docs/reference/rest/accept-account-agreements.md) | `POST /v1/account/agreements` | Free (0 credits) | | [Get account usage](https://emailmovers.com/mydatahub/docs/reference/rest/get-account-usage.md) | `GET /v1/account/usage` | Free (0 credits) | | [Cancel the plan](https://emailmovers.com/mydatahub/docs/reference/rest/cancel-account.md) | `POST /v1/account/cancel` | Free (0 credits) | | [List credit purchases](https://emailmovers.com/mydatahub/docs/reference/rest/list-credit-purchases.md) | `GET /v1/account/purchases` | Free (0 credits) | | [Buy credits by card](https://emailmovers.com/mydatahub/docs/reference/rest/create-credit-purchase.md) | `POST /v1/account/purchases` | Free (0 credits) | | [Get a credit purchase](https://emailmovers.com/mydatahub/docs/reference/rest/get-credit-purchase.md) | `GET /v1/account/purchases/{id}` | Free (0 credits) | ## Keys API keys and agent keys with budgets. | Operation | Endpoint | Cost | | --- | --- | --- | | [List API keys](https://emailmovers.com/mydatahub/docs/reference/rest/list-api-keys.md) | `GET /v1/api-keys` | Free (0 credits) | | [Create an API key](https://emailmovers.com/mydatahub/docs/reference/rest/create-api-key.md) | `POST /v1/api-keys` | Free (0 credits) | | [Update an API key](https://emailmovers.com/mydatahub/docs/reference/rest/update-api-key.md) | `PATCH /v1/api-keys/{id}` | Free (0 credits) | | [Revoke an API key](https://emailmovers.com/mydatahub/docs/reference/rest/revoke-api-key.md) | `DELETE /v1/api-keys/{id}` | Free (0 credits) | ## CRM push CRM connections, the New to you overlap check and record pushes. | Operation | Endpoint | Cost | | --- | --- | --- | | [List CRM connections](https://emailmovers.com/mydatahub/docs/reference/rest/list-connections.md) | `GET /v1/connections` | Free (0 credits) | | [Create a CRM connection](https://emailmovers.com/mydatahub/docs/reference/rest/create-connection.md) | `POST /v1/connections` | Free (0 credits) | | [Check CRM overlap](https://emailmovers.com/mydatahub/docs/reference/rest/check-connection-overlap.md) | `POST /v1/connections/{id}/overlap` | Free (0 credits) | | [Push an order to a CRM](https://emailmovers.com/mydatahub/docs/reference/rest/create-push.md) | `POST /v1/orders/{id}/pushes` | Free (0 credits) | | [Get a push](https://emailmovers.com/mydatahub/docs/reference/rest/get-push.md) | `GET /v1/pushes/{id}` | Free (0 credits) | | [Retry a push](https://emailmovers.com/mydatahub/docs/reference/rest/retry-push.md) | `POST /v1/pushes/{id}/retry` | Free (0 credits) | ## Webhooks Webhook endpoints and the 30 day event log. | Operation | Endpoint | Cost | | --- | --- | --- | | [List webhook endpoints](https://emailmovers.com/mydatahub/docs/reference/rest/list-webhook-endpoints.md) | `GET /v1/webhooks` | Free (0 credits) | | [Create a webhook endpoint](https://emailmovers.com/mydatahub/docs/reference/rest/create-webhook-endpoint.md) | `POST /v1/webhooks` | Free (0 credits) | | [Delete a webhook endpoint](https://emailmovers.com/mydatahub/docs/reference/rest/delete-webhook-endpoint.md) | `DELETE /v1/webhooks/{id}` | Free (0 credits) | | [Send a test event](https://emailmovers.com/mydatahub/docs/reference/rest/test-webhook-endpoint.md) | `POST /v1/webhooks/{id}/test` | Free (0 credits) | | [List events](https://emailmovers.com/mydatahub/docs/reference/rest/list-events.md) | `GET /v1/events` | Free (0 credits) | ## Meta This document and the health check. | Operation | Endpoint | Cost | | --- | --- | --- | | [Get this OpenAPI document](https://emailmovers.com/mydatahub/docs/reference/rest/get-open-api-document.md) | `GET /v1/openapi.json` | Free (0 credits) | | [Check health](https://emailmovers.com/mydatahub/docs/reference/rest/get-health.md) | `GET /v1/health` | Free (0 credits) | --- # MCP tool reference > The 22 MCP tools of https://mydatahub.emailmovers.com/mcp in their fixed order, and the 12 deprecated tools, generated from the tools/list result. Source: https://emailmovers.com/mydatahub/docs/reference/mcp The server at `https://mydatahub.emailmovers.com/mcp` returns 22 tools in this fixed order, with `ttlMs` 300000 and `cacheScope` public, so host prompt caches hit. Every tool has a title, annotations, an input schema that refuses unknown members, and an output schema. > **Not yet live.** MyDataHub v2 is built and tested locally and is not yet live. The v2 hosts open at launch, date to confirm. Looking is free: counting, previewing and quoting cost nothing. Only `create_order` and `enrich_contact` spend credits, and a spend needs a `quote_id` plus a person's confirmation or an agent key within its limits. See [Spending safely](https://emailmovers.com/mydatahub/docs/spending-safely.md). ## Tools | # | Tool | Title | Cost | Kind | Scope | | ---: | --- | --- | --- | --- | --- | | 1 | [`get_plans`](https://emailmovers.com/mydatahub/docs/reference/mcp/get_plans.md) | Plans and prices | Free | read | none | | 2 | [`get_account`](https://emailmovers.com/mydatahub/docs/reference/mcp/get_account.md) | Account and balance | Free | read | `account:read` | | 3 | [`list_filters`](https://emailmovers.com/mydatahub/docs/reference/mcp/list_filters.md) | Filter catalogue | Free | read | none | | 4 | [`search_filter_values`](https://emailmovers.com/mydatahub/docs/reference/mcp/search_filter_values.md) | Search filter values | Free | read | none | | 5 | [`expand_job_titles`](https://emailmovers.com/mydatahub/docs/reference/mcp/expand_job_titles.md) | Expand job titles | Free | read | none | | 6 | [`resolve_location`](https://emailmovers.com/mydatahub/docs/reference/mcp/resolve_location.md) | Resolve a location | Free | read | none | | 7 | [`compile_audience`](https://emailmovers.com/mydatahub/docs/reference/mcp/compile_audience.md) | Compile a brief | Free | read | none | | 8 | [`count_audience`](https://emailmovers.com/mydatahub/docs/reference/mcp/count_audience.md) | Count an audience | Free | read | none | | 9 | [`preview_records`](https://emailmovers.com/mydatahub/docs/reference/mcp/preview_records.md) | Preview sample records | Free | read | none | | 10 | [`save_audience`](https://emailmovers.com/mydatahub/docs/reference/mcp/save_audience.md) | Save an audience | Free | write | `audiences:write` | | 11 | [`list_audiences`](https://emailmovers.com/mydatahub/docs/reference/mcp/list_audiences.md) | List saved audiences | Free | read | `audiences:read` | | 12 | [`list_field_sets`](https://emailmovers.com/mydatahub/docs/reference/mcp/list_field_sets.md) | List field sets | Free | read | none | | 13 | [`quote_order`](https://emailmovers.com/mydatahub/docs/reference/mcp/quote_order.md) | Quote an order | Free | read | none | | 14 | [`create_order`](https://emailmovers.com/mydatahub/docs/reference/mcp/create_order.md) | Create an order (spends credits) | 1 credit per record | write | `orders:write` | | 15 | [`get_order`](https://emailmovers.com/mydatahub/docs/reference/mcp/get_order.md) | Get order status | Free | read | `orders:read` | | 16 | [`fetch_order_records`](https://emailmovers.com/mydatahub/docs/reference/mcp/fetch_order_records.md) | Fetch order records | Free under licence | read | `records:read` | | 17 | [`push_to_crm`](https://emailmovers.com/mydatahub/docs/reference/mcp/push_to_crm.md) | Push an order to a CRM | Free | write, open world | `pushes:write` | | 18 | [`lookup_company`](https://emailmovers.com/mydatahub/docs/reference/mcp/lookup_company.md) | Look up a company | Free | read | none | | 19 | [`enrich_contact`](https://emailmovers.com/mydatahub/docs/reference/mcp/enrich_contact.md) | Enrich a contact (spends credits) | 1 credit on match | write | `enrich:write` | | 20 | [`check_suppression`](https://emailmovers.com/mydatahub/docs/reference/mcp/check_suppression.md) | Check suppression | Free | read | `suppressions:read` | | 21 | [`report_objection`](https://emailmovers.com/mydatahub/docs/reference/mcp/report_objection.md) | Report an objection | Free | write | `suppressions:write` | | 22 | [`get_compliance_receipt`](https://emailmovers.com/mydatahub/docs/reference/mcp/get_compliance_receipt.md) | Get a compliance receipt | Free | read | `orders:read` | ## Deprecated tools 12 of today's 13 tools are still listed after the 22 above, with "Deprecated" in their titles. The thirteenth, `preview_records`, keeps its name: the new tool replaces it in place. They stop working 12 months after the v2 launch. | Deprecated tool | Use instead | | --- | --- | | [`available_selection_categories`](https://emailmovers.com/mydatahub/docs/reference/mcp/available_selection_categories.md) | `list_filters` | | [`available_selection_values`](https://emailmovers.com/mydatahub/docs/reference/mcp/available_selection_values.md) | `search_filter_values` | | [`search_discrete_values`](https://emailmovers.com/mydatahub/docs/reference/mcp/search_discrete_values.md) | `search_filter_values` | | [`create_search`](https://emailmovers.com/mydatahub/docs/reference/mcp/create_search.md) | `compile_audience`, then `count_audience` | | [`clear_search`](https://emailmovers.com/mydatahub/docs/reference/mcp/clear_search.md) | `count_audience` | | [`get_count`](https://emailmovers.com/mydatahub/docs/reference/mcp/get_count.md) | `count_audience` | | [`save_selections`](https://emailmovers.com/mydatahub/docs/reference/mcp/save_selections.md) | `save_audience` | | [`get_saved_selections`](https://emailmovers.com/mydatahub/docs/reference/mcp/get_saved_selections.md) | `list_audiences` | | [`create_data_extract`](https://emailmovers.com/mydatahub/docs/reference/mcp/create_data_extract.md) | `quote_order`, then `create_order` | | [`get_credits`](https://emailmovers.com/mydatahub/docs/reference/mcp/get_credits.md) | `get_account` | | [`get_packs`](https://emailmovers.com/mydatahub/docs/reference/mcp/get_packs.md) | `list_field_sets` | | [`get_pack_details`](https://emailmovers.com/mydatahub/docs/reference/mcp/get_pack_details.md) | `list_field_sets` | --- # Webhook events > The 11 webhook event types, with their payloads, generated from the OpenAPI document. Source: https://emailmovers.com/mydatahub/docs/reference/webhooks 11 event types. Each delivery is a Standard Webhooks request with three headers, `webhook-id`, `webhook-timestamp`, `webhook-signature`, and a JSON body of the shape below. Delivered to your endpoint under Standard Webhooks: verify webhook-signature (HMAC-SHA256 over webhook-id, webhook-timestamp and the body with your whsec_ secret) and treat webhook-id as the idempotency key. Any 2xx acknowledges; anything else is retried over 24 hours with backoff. Every payload carries `id` (also the `webhook-id` header, your idempotency key), `type`, `timestamp`, `api_version` and `data`. Ignore members you do not know: they may appear in a later `MyDataHub-Version`. `GET /v1/events` replays the last 30 days, oldest first, in the same shape. See [Webhooks](https://emailmovers.com/mydatahub/docs/webhooks.md) for signing, retries and verification code. The example payloads are illustrative. Each is checked against its schema when this page is generated. ## Event types | Event | Fires when | | --- | --- | | [`order.ready`](#order-ready) | Records are available. | | [`order.failed`](#order-failed) | Processing failed; credits are refunded. | | [`audience.refreshed`](#audience-refreshed) | A saved audience was recounted, with the diff. | | [`credits.low`](#credits-low) | The balance crossed the configured threshold. | | [`credits.topped_up`](#credits-topped-up) | A top-up settled. | | [`warranty.replacement_issued`](#warranty-replacement-issued) | A bounced or job-changed record was replaced, or re-credited. | | [`suppression.created`](#suppression-created) | An objection reached a record your organisation holds. Suppress it before any send. | | [`push.completed`](#push-completed) | A CRM push finished. | | [`push.partial`](#push-partial) | A CRM push partly finished; retry the failed records. | | [`push.failed`](#push-failed) | A CRM push failed. | | [`connection.expired`](#connection-expired) | A CRM token failed; reconnect in the app. | ## order.ready Records are available. Payload: `OrderReadyEvent`. | Member | Type | Always present | Description | | --- | --- | --- | --- | | `id` | string | yes | Also sent as the webhook-id header; your idempotency key. | | `type` | `order.ready` | yes | | | `timestamp` | string (date-time) | yes | | | `api_version` | string | yes | A dated MyDataHub-Version, such as 2027-01-15. | | `data` | object | yes | | | `data.order_id` | string | yes | Format: an order id: ord_ followed by a 26 character ULID in upper case Crockford base32, such as ord_01JB4N9S3W2X6Y7Z8A1B2C3D4E. | | `data.records` | integer (0 or more) | yes | | | `data.records_url` | string (uri-reference) | yes | A path on https://mydatahub.emailmovers.com starting /v1/. Resolve it against the origin (RFC 3986), not by appending it to the server URL. | | `data.receipt_url` | string (uri-reference) | yes | A path on https://mydatahub.emailmovers.com starting /v1/. Resolve it against the origin (RFC 3986), not by appending it to the server URL. | ```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" } } ``` ## order.failed Processing failed; credits are refunded. Payload: `OrderFailedEvent`. | Member | Type | Always present | Description | | --- | --- | --- | --- | | `id` | string | yes | Also sent as the webhook-id header; your idempotency key. | | `type` | `order.failed` | yes | | | `timestamp` | string (date-time) | yes | | | `api_version` | string | yes | A dated MyDataHub-Version, such as 2027-01-15. | | `data` | object | yes | | | `data.order_id` | string | yes | Format: an order id: ord_ followed by a 26 character ULID in upper case Crockford base32, such as ord_01JB4N9S3W2X6Y7Z8A1B2C3D4E. | | `data.reason` | string | yes | | | `data.credits_refunded` | number (0 or more) | yes | Credits, to one decimal place: whole at 1 credit a record, fractional only where a rule is (0.1 company-only, 1.8 outright, both proposed). | ```json { "id": "evt_01JB4NB7T1Y5W8ZAK8Q2V7XN3M", "type": "order.failed", "timestamp": "2026-09-26T10:16:04Z", "api_version": "2027-01-15", "data": { "order_id": "ord_01JB4N9S3W2X6Y7Z8A1B2C3D4E", "reason": "The audience could not be run.", "credits_refunded": 300 } } ``` ## audience.refreshed A saved audience was recounted, with the diff. Payload: `AudienceRefreshedEvent`. | Member | Type | Always present | Description | | --- | --- | --- | --- | | `id` | string | yes | Also sent as the webhook-id header; your idempotency key. | | `type` | `audience.refreshed` | yes | | | `timestamp` | string (date-time) | yes | | | `api_version` | string | yes | A dated MyDataHub-Version, such as 2027-01-15. | | `data` | `AudienceRefresh` | yes | A saved audience recounted on the current dataset version, with the difference. | | `data.audience_id` | string | yes | Format: an audience id: aud_ followed by a 26 character ULID in upper case Crockford base32, such as aud_01JB4H2K9V6XN3M9R6T1Y5W8ZA. | | `data.previous` | `AudienceCountSummary` or null | yes | | | `data.current` | `AudienceCountSummary` | yes | The headline numbers of one count of an audience. | | `data.diff` | object | yes | | ```json { "id": "evt_01JB4NB7T1Y5W8ZAK8Q2V7XN3M", "type": "audience.refreshed", "timestamp": "2026-09-26T10:16:04Z", "api_version": "2027-01-15", "data": { "audience_id": "aud_01JB4H2K9V6XN3M9R6T1Y5W8ZA", "previous": { "count_id": "cnt_7K2PQ4MAXN3R6TBY5WDZ", "dataset_version": "2026-W39", "contacts": 12840, "companies": 3115, "emailable": 12310, "counted_at": "2026-09-26T10:14:00Z" }, "current": { "count_id": "cnt_Q3N5X2M7K4P6R2T4V4W6", "dataset_version": "2026-W40", "contacts": 13054, "companies": 3160, "emailable": 12520, "counted_at": "2026-10-05T06:00:00Z" }, "diff": { "contacts": 214, "companies": 45, "emailable": 210, "new_records": 231, "removed_records": 17 } } } ``` ## credits.low The balance crossed the configured threshold. Payload: `CreditsLowEvent`. | Member | Type | Always present | Description | | --- | --- | --- | --- | | `id` | string | yes | Also sent as the webhook-id header; your idempotency key. | | `type` | `credits.low` | yes | | | `timestamp` | string (date-time) | yes | | | `api_version` | string | yes | A dated MyDataHub-Version, such as 2027-01-15. | | `data` | object | yes | | | `data.balance` | number | yes | | | `data.threshold` | integer (0 or more) | yes | | | `data.top_up_url` | string (uri) | yes | | ```json { "id": "evt_01JB4NB7T1Y5W8ZAK8Q2V7XN3M", "type": "credits.low", "timestamp": "2026-09-26T10:16:04Z", "api_version": "2027-01-15", "data": { "balance": 180, "threshold": 200, "top_up_url": "https://mydatahub.emailmovers.com/billing/top-up" } } ``` ## credits.topped_up A top-up settled. Payload: `CreditsToppedUpEvent`. | Member | Type | Always present | Description | | --- | --- | --- | --- | | `id` | string | yes | Also sent as the webhook-id header; your idempotency key. | | `type` | `credits.topped_up` | yes | | | `timestamp` | string (date-time) | yes | | | `api_version` | string | yes | A dated MyDataHub-Version, such as 2027-01-15. | | `data` | object | yes | | | `data.credits_added` | integer (0 or more) | yes | | | `data.balance` | number | yes | | | `data.source` | enum: `credit_pack`, `top_up`, `plan`, `refund`, `adjustment` | yes | credit_pack for credit packs bought by card, top_up for credits bought at the per-contact scale (with or without credit packs), plan for a plan's credits, refund or adjustment. | | `data.amount_gbp_ex_vat` | number (0 or more) or null | yes | | ```json { "id": "evt_01JB4NB7T1Y5W8ZAK8Q2V7XN3M", "type": "credits.topped_up", "timestamp": "2026-09-26T10:16:04Z", "api_version": "2027-01-15", "data": { "credits_added": 40000, "balance": 41200, "source": "credit_pack", "amount_gbp_ex_vat": 1249 } } ``` ## warranty.replacement_issued A bounced or job-changed record was replaced, or re-credited. Payload: `WarrantyReplacementEvent`. | Member | Type | Always present | Description | | --- | --- | --- | --- | | `id` | string | yes | Also sent as the webhook-id header; your idempotency key. | | `type` | `warranty.replacement_issued` | yes | | | `timestamp` | string (date-time) | yes | | | `api_version` | string | yes | A dated MyDataHub-Version, such as 2027-01-15. | | `data` | object | yes | | | `data.order_id` | string | yes | Format: an order id: ord_ followed by a 26 character ULID in upper case Crockford base32, such as ord_01JB4N9S3W2X6Y7Z8A1B2C3D4E. | | `data.retired_record_id` | string | yes | Format: a contact id: contact_ followed by a 26 character ULID in upper case Crockford base32, such as contact_01JA9X2V7XN3M9R6T1Y5W8ZAK8. | | `data.replacement_record_id` | string or null | yes | | | `data.reason` | enum: `hard_bounce`, `job_change` | yes | | | `data.credits_refunded` | number (0 or more) | yes | Credits, to one decimal place: whole at 1 credit a record, fractional only where a rule is (0.1 company-only, 1.8 outright, both proposed). | ```json { "id": "evt_01JB4NB7T1Y5W8ZAK8Q2V7XN3M", "type": "warranty.replacement_issued", "timestamp": "2026-09-26T10:16:04Z", "api_version": "2027-01-15", "data": { "order_id": "ord_01JB4N9S3W2X6Y7Z8A1B2C3D4E", "retired_record_id": "contact_01JA9X2V7XN3M9R6T1Y5W8ZAK8", "replacement_record_id": null, "reason": "hard_bounce", "credits_refunded": 1 } } ``` ## suppression.created An objection reached a record your organisation holds. Suppress it before any send. Payload: `SuppressionCreatedEvent`. | Member | Type | Always present | Description | | --- | --- | --- | --- | | `id` | string | yes | Also sent as the webhook-id header; your idempotency key. | | `type` | `suppression.created` | yes | | | `timestamp` | string (date-time) | yes | | | `api_version` | string | yes | A dated MyDataHub-Version, such as 2027-01-15. | | `data` | `SuppressionNotice` | yes | A record you hold that is now suppressed, as the suppression.created webhook carries it. | | `data.record_id` | string | yes | Format: a contact id: contact_ followed by a 26 character ULID in upper case Crockford base32, such as contact_01JA9X2V7XN3M9R6T1Y5W8ZAK8. | | `data.reason` | enum: `none`, `objected_to_emailmovers`, `objected_to_licensee`, `deceased`, `left_company` | yes | | | `data.scope` | enum: `licensee`, `emailmovers` | yes | | | `data.suppressed_at` | string (date-time) | yes | | ```json { "id": "evt_01JB4NB7T1Y5W8ZAK8Q2V7XN3M", "type": "suppression.created", "timestamp": "2026-09-26T10:16:04Z", "api_version": "2027-01-15", "data": { "record_id": "contact_01JA9X2V7XN3M9R6T1Y5W8ZAK8", "reason": "objected_to_emailmovers", "scope": "emailmovers", "suppressed_at": "2026-10-01T08:30:00Z" } } ``` ## push.completed A CRM push finished. Payload: `PushCompletedEvent`. | Member | Type | Always present | Description | | --- | --- | --- | --- | | `id` | string | yes | Also sent as the webhook-id header; your idempotency key. | | `type` | `push.completed` | yes | | | `timestamp` | string (date-time) | yes | | | `api_version` | string | yes | A dated MyDataHub-Version, such as 2027-01-15. | | `data` | `Push` | yes | A CRM push or its dry run, such as 4,102 to create and 0 already in your HubSpot. | | `data.id` | string | yes | Format: a CRM push id: psh_ followed by a 26 character ULID in upper case Crockford base32, such as psh_01JB4NC3W2X6Y7Z8A1B2C3D4E5. | | `data.order_id` | string | yes | Format: an order id: ord_ followed by a 26 character ULID in upper case Crockford base32, such as ord_01JB4N9S3W2X6Y7Z8A1B2C3D4E. | | `data.connection_id` | string | yes | Format: a CRM connection id: conn_ followed by a 26 character ULID in upper case Crockford base32, such as conn_01JB3Y2X6Y7Z8A1B2C3D4E5F6G. | | `data.dry_run` | boolean | yes | | | `data.status` | enum: `queued`, `running`, `completed`, `partial`, `failed` | yes | | | `data.summary` | object | yes | | | `data.property_group` | array of enum: `mydatahub_record_id`, `licence_expires_at`, `lawful_basis`, `article14_status`, `tps_ctps_screened_at`, `subscriber_class`, `suppression_status`, `compliance_receipt_url` | yes | The MyDataHub property group written on every record (CrmPropertyGroup). | | `data.sample` | `CrmPropertyGroup` or null | yes | On a dry run: the property group the first record would carry. | | `data.errors` | array of object | yes | | | `data.created_at` | string (date-time) | yes | | | `data.completed_at` | string (date-time) or null | yes | | ```json { "id": "evt_01JB4NB7T1Y5W8ZAK8Q2V7XN3M", "type": "push.completed", "timestamp": "2026-09-26T10:16:04Z", "api_version": "2027-01-15", "data": { "id": "psh_01JB4NC3W2X6Y7Z8A1B2C3D4E5", "order_id": "ord_01JB4N9S3W2X6Y7Z8A1B2C3D4E", "connection_id": "conn_01JB3Y2X6Y7Z8A1B2C3D4E5F6G", "dry_run": false, "status": "completed", "summary": { "to_create": 4102, "to_update": 0, "already_present": 0, "skipped": 0, "failed": 0 }, "property_group": [ "mydatahub_record_id", "licence_expires_at", "lawful_basis", "article14_status", "tps_ctps_screened_at", "subscriber_class", "suppression_status", "compliance_receipt_url" ], "sample": { "mydatahub_record_id": "contact_01JA9X2V7XN3M9R6T1Y5W8ZAK8", "licence_expires_at": "2027-09-26", "lawful_basis": "legitimate_interests_art6_1_f", "article14_status": "notified", "tps_ctps_screened_at": "2026-09-12", "subscriber_class": "ltd", "suppression_status": "none", "compliance_receipt_url": "https://mydatahub.emailmovers.com/receipts/rcpt_01JB4NA2Q5W8ZAK8Q2V7XN3M9R" }, "errors": [], "created_at": "2026-09-26T10:20:00Z", "completed_at": "2026-09-26T10:20:03Z" } } ``` ## push.partial A CRM push partly finished; retry the failed records. Payload: `PushPartialEvent`. | Member | Type | Always present | Description | | --- | --- | --- | --- | | `id` | string | yes | Also sent as the webhook-id header; your idempotency key. | | `type` | `push.partial` | yes | | | `timestamp` | string (date-time) | yes | | | `api_version` | string | yes | A dated MyDataHub-Version, such as 2027-01-15. | | `data` | `Push` | yes | A CRM push or its dry run, such as 4,102 to create and 0 already in your HubSpot. | | `data.id` | string | yes | Format: a CRM push id: psh_ followed by a 26 character ULID in upper case Crockford base32, such as psh_01JB4NC3W2X6Y7Z8A1B2C3D4E5. | | `data.order_id` | string | yes | Format: an order id: ord_ followed by a 26 character ULID in upper case Crockford base32, such as ord_01JB4N9S3W2X6Y7Z8A1B2C3D4E. | | `data.connection_id` | string | yes | Format: a CRM connection id: conn_ followed by a 26 character ULID in upper case Crockford base32, such as conn_01JB3Y2X6Y7Z8A1B2C3D4E5F6G. | | `data.dry_run` | boolean | yes | | | `data.status` | enum: `queued`, `running`, `completed`, `partial`, `failed` | yes | | | `data.summary` | object | yes | | | `data.property_group` | array of enum: `mydatahub_record_id`, `licence_expires_at`, `lawful_basis`, `article14_status`, `tps_ctps_screened_at`, `subscriber_class`, `suppression_status`, `compliance_receipt_url` | yes | The MyDataHub property group written on every record (CrmPropertyGroup). | | `data.sample` | `CrmPropertyGroup` or null | yes | On a dry run: the property group the first record would carry. | | `data.errors` | array of object | yes | | | `data.created_at` | string (date-time) | yes | | | `data.completed_at` | string (date-time) or null | yes | | ```json { "id": "evt_01JB4NB7T1Y5W8ZAK8Q2V7XN3M", "type": "push.partial", "timestamp": "2026-09-26T10:16:04Z", "api_version": "2027-01-15", "data": { "id": "psh_01JB4NC3W2X6Y7Z8A1B2C3D4E5", "order_id": "ord_01JB4N9S3W2X6Y7Z8A1B2C3D4E", "connection_id": "conn_01JB3Y2X6Y7Z8A1B2C3D4E5F6G", "dry_run": false, "status": "partial", "summary": { "to_create": 4090, "to_update": 0, "already_present": 0, "skipped": 0, "failed": 12 }, "property_group": [ "mydatahub_record_id", "licence_expires_at", "lawful_basis", "article14_status", "tps_ctps_screened_at", "subscriber_class", "suppression_status", "compliance_receipt_url" ], "sample": { "mydatahub_record_id": "contact_01JA9X2V7XN3M9R6T1Y5W8ZAK8", "licence_expires_at": "2027-09-26", "lawful_basis": "legitimate_interests_art6_1_f", "article14_status": "notified", "tps_ctps_screened_at": "2026-09-12", "subscriber_class": "ltd", "suppression_status": "none", "compliance_receipt_url": "https://mydatahub.emailmovers.com/receipts/rcpt_01JB4NA2Q5W8ZAK8Q2V7XN3M9R" }, "errors": [], "created_at": "2026-09-26T10:20:00Z", "completed_at": "2026-09-26T10:20:03Z" } } ``` ## push.failed A CRM push failed. Payload: `PushFailedEvent`. | Member | Type | Always present | Description | | --- | --- | --- | --- | | `id` | string | yes | Also sent as the webhook-id header; your idempotency key. | | `type` | `push.failed` | yes | | | `timestamp` | string (date-time) | yes | | | `api_version` | string | yes | A dated MyDataHub-Version, such as 2027-01-15. | | `data` | `Push` | yes | A CRM push or its dry run, such as 4,102 to create and 0 already in your HubSpot. | | `data.id` | string | yes | Format: a CRM push id: psh_ followed by a 26 character ULID in upper case Crockford base32, such as psh_01JB4NC3W2X6Y7Z8A1B2C3D4E5. | | `data.order_id` | string | yes | Format: an order id: ord_ followed by a 26 character ULID in upper case Crockford base32, such as ord_01JB4N9S3W2X6Y7Z8A1B2C3D4E. | | `data.connection_id` | string | yes | Format: a CRM connection id: conn_ followed by a 26 character ULID in upper case Crockford base32, such as conn_01JB3Y2X6Y7Z8A1B2C3D4E5F6G. | | `data.dry_run` | boolean | yes | | | `data.status` | enum: `queued`, `running`, `completed`, `partial`, `failed` | yes | | | `data.summary` | object | yes | | | `data.property_group` | array of enum: `mydatahub_record_id`, `licence_expires_at`, `lawful_basis`, `article14_status`, `tps_ctps_screened_at`, `subscriber_class`, `suppression_status`, `compliance_receipt_url` | yes | The MyDataHub property group written on every record (CrmPropertyGroup). | | `data.sample` | `CrmPropertyGroup` or null | yes | On a dry run: the property group the first record would carry. | | `data.errors` | array of object | yes | | | `data.created_at` | string (date-time) | yes | | | `data.completed_at` | string (date-time) or null | yes | | ```json { "id": "evt_01JB4NB7T1Y5W8ZAK8Q2V7XN3M", "type": "push.failed", "timestamp": "2026-09-26T10:16:04Z", "api_version": "2027-01-15", "data": { "id": "psh_01JB4NC3W2X6Y7Z8A1B2C3D4E5", "order_id": "ord_01JB4N9S3W2X6Y7Z8A1B2C3D4E", "connection_id": "conn_01JB3Y2X6Y7Z8A1B2C3D4E5F6G", "dry_run": false, "status": "failed", "summary": { "to_create": 0, "to_update": 0, "already_present": 0, "skipped": 0, "failed": 4102 }, "property_group": [ "mydatahub_record_id", "licence_expires_at", "lawful_basis", "article14_status", "tps_ctps_screened_at", "subscriber_class", "suppression_status", "compliance_receipt_url" ], "sample": { "mydatahub_record_id": "contact_01JA9X2V7XN3M9R6T1Y5W8ZAK8", "licence_expires_at": "2027-09-26", "lawful_basis": "legitimate_interests_art6_1_f", "article14_status": "notified", "tps_ctps_screened_at": "2026-09-12", "subscriber_class": "ltd", "suppression_status": "none", "compliance_receipt_url": "https://mydatahub.emailmovers.com/receipts/rcpt_01JB4NA2Q5W8ZAK8Q2V7XN3M9R" }, "errors": [], "created_at": "2026-09-26T10:20:00Z", "completed_at": "2026-09-26T10:20:03Z" } } ``` ## connection.expired A CRM token failed; reconnect in the app. Payload: `ConnectionExpiredEvent`. | Member | Type | Always present | Description | | --- | --- | --- | --- | | `id` | string | yes | Also sent as the webhook-id header; your idempotency key. | | `type` | `connection.expired` | yes | | | `timestamp` | string (date-time) | yes | | | `api_version` | string | yes | A dated MyDataHub-Version, such as 2027-01-15. | | `data` | object | yes | | | `data.connection_id` | string | yes | Format: a CRM connection id: conn_ followed by a 26 character ULID in upper case Crockford base32, such as conn_01JB3Y2X6Y7Z8A1B2C3D4E5F6G. | | `data.provider` | string | yes | | | `data.reconnect_url` | string (uri) | yes | | ```json { "id": "evt_01JB4NB7T1Y5W8ZAK8Q2V7XN3M", "type": "connection.expired", "timestamp": "2026-09-26T10:16:04Z", "api_version": "2027-01-15", "data": { "connection_id": "conn_01JB3Y2X6Y7Z8A1B2C3D4E5F6G", "provider": "hubspot", "reconnect_url": "https://mydatahub.emailmovers.com/api-keys" } } ``` --- # Migration guide > Move from today's 13 MCP tools and the RunExtract endpoint to v2. A table maps every old call to its replacement, with worked before and after examples. Source: https://emailmovers.com/mydatahub/docs/migrate Updated: 16 October 2026 Every one of today's 13 MCP tools has a replacement among the 22 new ones, and the legacy download endpoint has one too. The plan is a 12 month window from the v2 launch, with 3 emailed notices, in which today's tools, keys and installs keep working while you move. Installs at today's MCP URL, `https://mcp.mydatahub.uk`, are planned to keep working: it passes MCP through to the new server at `https://mydatahub.emailmovers.com/mcp` for at least 12 months, with no redirect, and credit balances and saved lists are planned to carry over. > **Not yet live.** MyDataHub v2 is built and tested locally and is not yet live. The 12 months run from the launch date, which is to confirm. Until then, today's tools and endpoints are the current product. > **To confirm.** The old MCP tools are built into the v2 server and answer as the table below says. Three other parts of the plan are not built, so do not rely on them yet. - The `X-Api-Key` header and `POST /api/RunExtract`. The v2 API does not accept the header, has no such route and sends no `Deprecation` or `Sunset` header. - Tokens from today's OAuth issuer. The v2 API refuses any bearer token that is not an `mdh_` key. - The redirect from `agents.mydatahub.uk` to this site, page for page. Moving traffic to v2 without them would break today's integrations and installs. The launch has to leave today's endpoints where they are until they exist (to confirm). ## What changes | Today | After migration, as planned | | --- | --- | | 13 MCP tools that hold a session on the server | 22 tools where every call carries its own audience | | Numeric `structureId` values | Field names such as `company.employee_band`, from `list_filters` | | `incEx` codes 0, 1 and 2 | `in` to include, and `in` under `not` to exclude | | `create_data_extract` with `confirmed` true | `quote_order`, then `create_order` with a person's confirmation or an agent key | | `POST /api/RunExtract` with `X-Api-Key` | `GET /v1/orders/{id}/records` with `Authorization: Bearer` | | Credit balance | An opening ledger entry, one for one, with licence dates unchanged | | Saved lists | Audiences with `aud_` ids, in the same organisation | | 29 export bundles, five of them tests | Named field sets, 1 credit a record each. The test bundles are removed | | `agents.mydatahub.uk` | This site, with a permanent redirect page for page (not built) | The plan is for the old tools, the `X-Api-Key` header and `POST /api/RunExtract` to stay available for 12 months from the v2 launch date, with 3 emailed notices, and then to return 410, with `Deprecation` and `Sunset` headers on legacy download responses (RFC 9745 and RFC 8594). Only the old MCP tools are built so far. The v2 API has no `X-Api-Key` handling, no `/api/RunExtract` route and no deprecation headers yet, so those parts of the plan are to confirm. ## The 13 tools The old tools are listed after the 22 new ones, with "Deprecated" in their titles and the replacement named in each description. `preview_records` is the thirteenth: the new tool keeps the name and replaces it in place, so a name appears once in `tools/list`. The last column says what each old tool does on the v2 server now. | Today's tool | Replaced by | What the replacement does | During the window | | --- | --- | --- | --- | | `available_selection_categories` | [`list_filters`](https://emailmovers.com/mydatahub/docs/reference/mcp/list_filters.md) | It returns every field by name, such as company.employee_band, with its type, operators and value count. | Runs list_filters and answers with its result. | | `available_selection_values` | [`search_filter_values`](https://emailmovers.com/mydatahub/docs/reference/mcp/search_filter_values.md) | It takes the field name and search text and returns codes with contact and company counts. | Runs search_filter_values. A structureId with no field, or an offset above 0, is refused with a next step. | | `search_discrete_values` | [`search_filter_values`](https://emailmovers.com/mydatahub/docs/reference/mcp/search_filter_values.md) | It returns codes with counts and did_you_mean for near misses. | Runs search_filter_values with the first value as the search text. A structureId with no field is refused with a next step. | | `create_search` | [`compile_audience`](https://emailmovers.com/mydatahub/docs/reference/mcp/compile_audience.md), then [`count_audience`](https://emailmovers.com/mydatahub/docs/reference/mcp/count_audience.md) | count_audience takes the whole audience definition in one call, so there is no session state to build up, and compile_audience turns a brief into that definition. | Counts the filters you send with count_audience. A range, or a structureId with no field, is refused with a next step. Nothing is kept for later calls. | | `clear_search` | [`count_audience`](https://emailmovers.com/mydatahub/docs/reference/mcp/count_audience.md) | There is nothing to clear: every count_audience call carries its own audience. | Succeeds and does nothing, because there is no session to clear. | | `get_count` | [`count_audience`](https://emailmovers.com/mydatahub/docs/reference/mcp/count_audience.md) | It returns Total, New to you and Emailable with breakdowns, quality, the compliance summary, a price estimate and a count id. | Answers an error that names count_audience, because the filters it read were held in a session that no longer exists. | | `preview_records` | [`preview_records`](https://emailmovers.com/mydatahub/docs/reference/mcp/preview_records.md) | The new preview_records keeps the name and takes an audience or a count_id instead; a call in the old form fails validation with a next_step naming count_audience. | Not listed under its old form, because the new tool has the same name. A call in the old form fails validation with a next step that names count_audience. | | `save_selections` | [`save_audience`](https://emailmovers.com/mydatahub/docs/reference/mcp/save_audience.md) | It saves an audience definition shared across the organisation; saved lists carry over as audiences (aud_). | Answers an error that names save_audience, because the filters it saved were held in a session that no longer exists. | | `get_saved_selections` | [`list_audiences`](https://emailmovers.com/mydatahub/docs/reference/mcp/list_audiences.md) | Saved lists carry over as audiences (aud_) in the same organisation, with their current counts. | Runs list_audiences. An offset above 0 is refused: pass next_cursor instead. | | `create_data_extract` | [`quote_order`](https://emailmovers.com/mydatahub/docs/reference/mcp/quote_order.md), then [`create_order`](https://emailmovers.com/mydatahub/docs/reference/mcp/create_order.md) | The flag is ignored, since a flag set by a model is never consent: the call makes a machine quote for the list, and the user confirms the spend in a form or on mydatahub.emailmovers.com, or an agent key covers it within its limits; otherwise nothing is charged and it returns the approval_url. | Ignores confirmed. It needs the aud_ id of a saved audience, counts it, makes a machine quote and goes through the same confirmation as create_order. With neither a person's approval nor an agent key, nothing is charged and it returns the approval_url. | | `get_credits` | [`get_account`](https://emailmovers.com/mydatahub/docs/reference/mcp/get_account.md) | It returns the balance by source, the plan, per-action prices, the rate limit and any agent key budget; balances carry over one for one. | Runs get_account and answers with its result. | | `get_packs` | [`list_field_sets`](https://emailmovers.com/mydatahub/docs/reference/mcp/list_field_sets.md) | The old bundles become named field sets with their exact columns; the five test bundles are removed. | Runs list_field_sets and answers with its result. | | `get_pack_details` | [`list_field_sets`](https://emailmovers.com/mydatahub/docs/reference/mcp/list_field_sets.md) | list_field_sets returns every field set with its exact columns and credits per record. | Runs list_field_sets and adds that the old bundle ids became field set names. | ## What the old tools do meanwhile During the window each deprecated tool answers as the last column above says. Nine run the tool that replaces them, three return an error that names the replacement, and `clear_search` succeeds and does nothing. - The answer is the new tool's result, with a "Deprecated:" line first and the replacement in `_meta["uk.mydatahub/deprecated"]`. - A numeric `structureId` is mapped to a field name (the tables below), `search` becomes `q`, and `limit` is capped at 100. An `offset` above 0 has no equivalent: pass `next_cursor` instead. - `create_search` turns its filters into an audience and counts it. Includes become `in`, and excludes go under `not`. A range filter has no equivalent. - `get_count` and `save_selections` read and wrote filters held in a server session, and sessions no longer exist. They return an error that names `count_audience` and `save_audience`. - `preview_records` in the old form fails validation with a `next_step` that names `count_audience`. - `clear_search` succeeds and does nothing, because there is nothing to clear. - `create_data_extract` ignores `confirmed`. It makes a machine quote for the saved list and goes through the same confirmation as `create_order`. With neither a person's approval nor an agent key, nothing is charged and it returns the `approval_url`. A retried `create_data_extract` for the same list, field set and volume reuses the live quote and the same idempotency key, so it finds the order it made and cannot buy a second batch. For another batch, call `quote_order` and then `create_order` with a new `idempotency_key`. A saved list's old numeric id has no mapping: call `list_audiences` to find its `aud_` id. ## Old ids to field names These old `structureId` values each map to a field. Several old ids can stand for one field. | Old `structureId` | New field | | --- | --- | | 1 | `site.region` | | 2 | `site.town` | | 3 | `site.postcode` | | 5 | `company.turnover_band` | | 6 | `company.employee_band` | | 8 | `email.type` | | 9, 20 | `company.business_class` | | 10, 11, 12 | `company.sic_2007` | | 14, 18, 31 | `contact.job_function` | | 17 | `company.fleet_band` | | 21 | `site.county` | | 27 | `company.type` | | 28 | `company.status` | | 30 | `contact.job_level_rank` | These old ids have no field. A call that names one is refused with a next step. | Old `structureId` | What the old catalogue called it | What to use now | | --- | --- | --- | | 15 | `Company name` | lookup_company finds a company by its name. | | 34 | `Email` | enrich_contact looks a person up by email address. | | 23 | `Email universe` | The defaults, which exclude generic addresses and dissolved companies. email.exists and email.type are the filters. | | 22 | `Phone universe` | phone.exists. | | 24 | `Mail universe` | No equivalent field. | | 19 | `TPS` | phone.tps_ctps_status. Whenever a phone is requested, numbers a screen found TPS or CTPS registered are left out by default; numbers not screened are kept and say so. | | 13 | `Tag` | No equivalent field: tags were for internal use. | | 25, 26 | `Matched jobs and Match prospects` | No equivalent field: they were internal matching references. | | 29 | `Research` | No equivalent field: an internal research universe. | | 36 | `Internal quality flag` | No equivalent field. | ## Old bundles to field sets A telephone bundle becomes `standard_telephone`, the Full bundle becomes `full`, and every other bundle is delivered as `standard`. Check the columns with `list_field_sets` before you rely on one. | Old `packId` | What the old catalogue called it | Field set | | ---: | --- | --- | | 545 | `Test1` | `standard`, because the test bundles are removed | | 548 | `Standard` | `standard` | | 549 | `Postal` | `standard` | | 550 | `Telephone` | `standard_telephone` | | 551 | `Postal and Telephone` | `standard_telephone` | | 552 | `Business Class` | `standard` | | 553 | `Job Level` | `standard` | | 554 | `Job Function` | `standard` | | 555 | `Full` | `full` | | 591 | `Linked In Pack` | `standard` | | 599 | `Job_Level_NEW` | `standard` | | 607 | `TEST` | `standard`, because the test bundles are removed | | 613 | `Eventbrite Pack` | `standard`, because the test bundles are removed | | 615 | `Testtag Pack` | `standard`, because the test bundles are removed | | 618 | `Sample` | `standard` | | 622 | `Boost + LinkedIn` | `standard` | | 762 | `Phone Pack` | `standard_telephone` | | 783 | `Website Only` | `standard` | | 822 | `Data with Tel` | `standard_telephone` | | 824 | `Telephone Pack` | `standard_telephone` | | 826 | `Email Pack` | `standard` | | 837 | `STANDARD PLUS LINKEDIN` | `standard` | | 843 | `STANDARD PLUS WEBISTE` | `standard` | | 848 | `Skeleton` | `standard` | | 887 | `Company Number` | `standard` | | 906 | `Agent Pack` | `standard` | | 936 | `84013` | `standard`, because the test bundles are removed | | 968 | `TEL_JOB_LEVEL` | `standard_telephone` | | 1036 | `Linkedin PLUS TEL` | `standard_telephone` | ## Before and after The values in the old calls are illustrative. Use `list_filters` and `search_filter_values` for the codes your account sees. ### 1. Counting without a session Before, an agent built the count up on the server, one call at a time. 1. `available_selection_categories`, to learn the numeric ids. 2. `available_selection_values` with `{ "structureId": 6 }`, to learn the codes. 3. `create_search` with the filters to include or exclude. 4. `get_count`, to read the number held in the session. ```json { "selections": [ { "structure": 6, "fieldValue": "E", "incEx": 1 }, { "structure": 28, "fieldValue": "dissolved", "incEx": 2 } ] } ``` After, one call carries everything and returns the count, the breakdowns and an id. ```json { "name": "count_audience", "arguments": { "audience": { "schema": "mydatahub.audience/1", "dataset": "uk_contacts", "where": { "all": [ { "field": "company.employee_band", "op": "gte", "value": "50-99" }, { "not": { "field": "company.status", "op": "in", "values": ["dissolved"] } } ] } } } } ``` There is nothing to clear and nothing to remember between calls. Dissolved companies are already excluded by default, and the exclusion above is only to show the shape. See [The audience definition](https://emailmovers.com/mydatahub/docs/audience.md). ### 2. Finding a value Before, `available_selection_values` took a numeric id and paged with `offset`. ```json { "structureId": 10, "search": "manufact", "limit": 50 } ``` After, `search_filter_values` takes the field name and pages with a cursor. It returns codes with contact and company counts, and `did_you_mean` for near misses. ```json { "dimension": "company.sic_2007", "q": "manufact", "limit": 50 } ``` ### 3. Saving and listing Before, `save_selections` saved whatever the session held, and `get_saved_selections` listed the saved lists. After, you save a definition by name and list audiences with their current counts. ```json { "name": "Manufacturers, 50 or more staff", "audience": { "schema": "mydatahub.audience/1", "where": { "all": [ { "field": "company.sic_2007", "op": "in", "values": ["C"] }, { "field": "company.employee_band", "op": "gte", "value": "50-99" } ] } } } ``` That is the input of `save_audience`. Audiences are shared across your organisation, so the old `forUserGroup` and `days` arguments are not needed. ### 4. Ordering Before, the model's own flag spent the credits. ```json { "savedSelectionId": 1234, "packId": 548, "volumeRequired": 500, "confirmed": true } ``` That is `create_data_extract`. The old `packId` 548 is the Standard bundle. After, a quote comes first and a person confirms. 1. `quote_order` with `{ "count_id": "cnt_7K2PQ4MAXN3R6TBY5WDZ", "field_set": "standard", "limit": 500 }` returns a machine quote. 2. `create_order` with the quote and an idempotency key. The host asks the person to approve, or an agent key within its limits places the order. ```json { "name": "create_order", "arguments": { "quote_id": "qte_01JB4M2R6T1Y5W8ZAK8Q2V7XN3", "idempotency_key": "6f1c3b2a-9d4e-4f8a-b1c2-7e5d9a0f3c21", "name": "Leeds marketing Q4" } } ``` Every old `packId` and its field set is in [Old bundles to field sets](#old-bundles-to-field-sets). See [Spending safely](https://emailmovers.com/mydatahub/docs/spending-safely.md). ### 5. Getting the records Before, the legacy download endpoint paged records by number. It took `OrderId`, `pageNumber` and `pageSize`, and the key travelled in the `X-Api-Key` header. ```http POST https://app.mydatahub.uk/api/RunExtract X-Api-Key: ``` After, `GET /v1/orders/{id}/records` pages by cursor with a bearer token. The default page is 50 records and the maximum 500, and a page can hold fewer when it reaches about 8,000 tokens. ```bash curl "https://mydatahub.emailmovers.com/v1/orders/ord_01JB4N9S3W2X6Y7Z8A1B2C3D4E/records?limit=500" \ -H "Authorization: Bearer $MYDATAHUB_API_KEY" ``` Follow `next_cursor` until `has_more` is false. Over MCP the tool is `fetch_order_records`, with at most 100 rows a page. The plan is for legacy download responses to carry `Deprecation` and `Sunset` headers until the endpoint returns 410. The v2 API does not serve that endpoint yet. ### 6. Checking the balance Before, `get_credits` returned the balance. After, `get_account` takes no arguments and returns the balance by source, the plan, per-action prices, the rate limit and any agent key budget. ```json { "name": "get_account", "arguments": {} } ``` ## Migration checklist 1. Make an `mdh_test_` key in the app, and point your integration at it. Test keys return synthetic data and never charge. 2. Replace `X-Api-Key` with `Authorization: Bearer`. 3. Replace the session calls with one `count_audience` call per audience. 4. Replace `create_data_extract` and the legacy download endpoint with `quote_order`, `create_order` and `fetch_order_records`. 5. Decide how spends are confirmed: a person in the app, or an agent key with a budget and a per-order cap. See [Authentication](https://emailmovers.com/mydatahub/docs/authentication.md). 6. Subscribe to `order.ready` instead of polling. See [Webhooks](https://emailmovers.com/mydatahub/docs/webhooks.md). 7. Switch to an `mdh_live_` key when the test run passes. ## Questions **Do I need to reinstall the connector?** No. `https://mcp.mydatahub.uk` keeps serving today's product after launch, so an install made there keeps working as it is. Once the new server supports OAuth sign-in from the client, that address passes MCP through to `https://mydatahub.emailmovers.com/mcp` with no redirect. To use the 22 new tools before then, add the new server with a MyDataHub API key, as the [Quickstart for agents](https://emailmovers.com/mydatahub/docs/quickstart/agents.md) shows. **Will my saved lists and credits survive?** That is the plan. Saved lists become audiences in the same organisation, and the credit balance carries over one for one with licence dates unchanged (to confirm). **Can an old agent still spend?** Only through a person's confirmation or an agent key. A flag set by a model is never consent, in the old tool as in the new ones. **What happens when the window closes?** The plan is that the old tools stop being listed, and that the old header and endpoint return 410. See [Changelog and deprecation policy](https://emailmovers.com/mydatahub/docs/changelog.md). ## Next steps - Start with the [Quickstart for agents](https://emailmovers.com/mydatahub/docs/quickstart/agents.md) or the [Quickstart for developers](https://emailmovers.com/mydatahub/docs/quickstart/developers.md). - Browse the replacements in the [MCP tool reference](https://emailmovers.com/mydatahub/docs/reference/mcp.md). --- # Changelog and deprecation policy > What changes in each release, and how long old things last: 12 months of notice for breaking changes and for today's 13 MCP tools, X-Api-Key and RunExtract. Source: https://emailmovers.com/mydatahub/docs/changelog Updated: 16 October 2026 Nothing is withdrawn without 12 months and three emailed notices. This page lists what changes, and the policy that governs it. Version 2 has not launched, so the changelog has one entry: what v2 introduces. > **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. ## Deprecation policy 1. **Non-breaking changes ship as a new dated version.** You opt in by sending the new `MyDataHub-Version` date, and your key's pinned version applies until you do. See [Versions and reproducibility](https://emailmovers.com/mydatahub/docs/versions.md). 2. **Breaking changes change the path major.** The old major stays available for 12 months. There are no `/v2` paths. 3. **Response objects may gain members.** Ignore members you do not know. Removing or renaming a member, or changing its meaning, is a breaking change. 4. **Deprecated things say so in the response.** REST responses from a deprecated endpoint will carry `Deprecation` and `Sunset` headers (RFC 9745 and RFC 8594), though no endpoint is deprecated yet and the API sends neither. Deprecated MCP tools carry "Deprecated" in their titles, name their replacement in their descriptions, and put the replacement in the result's `_meta`. 5. **Three emailed notices before anything stops.** After the window, the old thing returns 410. 6. **Webhook endpoints stay pinned.** An endpoint keeps the version it was created with until you create a new one. ## What is deprecated today The plan is for the old tools, the `X-Api-Key` header and `POST /api/RunExtract` to stay available for 12 months from the v2 launch date, with 3 emailed notices, and then to return 410, with `Deprecation` and `Sunset` headers on legacy download responses (RFC 9745 and RFC 8594). Only the old MCP tools are built so far. The v2 API has no `X-Api-Key` handling, no `/api/RunExtract` route and no deprecation headers yet, so those parts of the plan are to confirm. | Deprecated | Replacement | Guide | | --- | --- | --- | | The 13 session-bound MCP tools | The 22 tools | [Migration guide](https://emailmovers.com/mydatahub/docs/migrate.md) | | The `X-Api-Key` header | `Authorization: Bearer` with an `mdh_` key | [Authentication](https://emailmovers.com/mydatahub/docs/authentication.md) | | `POST /api/RunExtract` | `GET /v1/orders/{id}/records` | [Migration guide](https://emailmovers.com/mydatahub/docs/migrate.md) | | `agents.mydatahub.uk` | This site. A page for page redirect is planned and not built | | The 12 months run from the v2 launch date, which is to confirm. The plan is for credit balances and saved lists to carry over. `https://mcp.mydatahub.uk` keeps serving today's product until the new server supports OAuth sign-in from the client, so installs made there keep working. ## Changelog ### 2.0.0 (not yet released) The first version of the new API, MCP server and app. The dated API version is `2027-01-15`. **New** - One audience definition, `mydatahub.audience/1`, for the app, REST and MCP. - Counts with Total, New to you and Emailable, up to three breakdowns, quality signals, a compliance summary and a content-addressed id that replays for 12 months. - Masked samples with why each record matches and a provenance card. - Machine quotes, signed with ES256 and valid for 24 hours. - Orders under quote then confirm: a person's confirmation or an agent key within its limits. A flag set by a model is never consent. - Agent keys with a monthly budget and a per-order cap. - Signed compliance receipts and `POST /v1/compliance/receipts/verify`. - Suppression lists, a check before every send and database-wide objections that reach every licensee. - Webhooks under Standard Webhooks, with 11 event types and a 30 day replay. - A CRM push that starts as a dry run. - 22 MCP tools in a fixed order, with resources and prompts. - RFC 9457 problem details with `next_step` and `did_you_mean` on every error. **Changed** - Saved lists become audiences with `aud_` ids in the same organisation. - The 29 export bundles become named field sets. At launch there are three: `standard`, `standard_telephone` and `full`, each 1 credit a record. The test bundles are removed. - `create_data_extract` ignores its `confirmed` flag and goes through the same quote and confirmation as `create_order`. - Credit balances carry over one for one, with licence dates unchanged. **Deprecated** - The 13 session-bound MCP tools, the `X-Api-Key` header and `POST /api/RunExtract`, all for 12 months from launch. **Not in 2.0.0** - Partner and Irish data. `ie_contacts` and `global_contacts` are listed but not served. - Five tools reserved for phase 2 behind scopes and not listed: `verify_emails`, `list_signals`, `create_alert`, `refresh_audience` and `delete_audience`. - Machine Payments Protocol on the order endpoint. ## Dataset versions Data changes ship as weekly dataset versions, not API versions. Each version has its own changelog at `GET /v1/datasets/{dataset}/versions/{version}`. See [Versions and reproducibility](https://emailmovers.com/mydatahub/docs/versions.md). ## Next steps - Move from today's surface with the [Migration guide](https://emailmovers.com/mydatahub/docs/migrate.md). - Check which version you are on with [Versions and reproducibility](https://emailmovers.com/mydatahub/docs/versions.md).