# Authentication (/docs/api/authentication) Every request needs an `Authorization: Bearer ` header. Keys look like `dm_live_<40 hex>` and are scoped to a single team. Creating a key [#creating-a-key] In the [API Keys dashboard](/dashboard/api-keys), click **New key** and give it a recognizable name (usually the environment or service that will use it). You will see the full key once in a dialog. After that, only the prefix is retrievable. ``` dm_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ``` Store the key in an environment variable, never in source control. Sending the header [#sending-the-header] ``` Authorization: Bearer dm_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ``` Applies to every `POST /api/v1/*` endpoint. Rotating and revoking [#rotating-and-revoking] Clicking **Revoke** in the dashboard deactivates a key immediately. The next request with a revoked key returns `401`. To rotate, create a new key first, deploy it, then revoke the old one. What keys see [#what-keys-see] A key can only read and modify data that belongs to its creator's team. Cross-team access is impossible by construction. * Only team owners and admins can create or revoke keys. * Team members see a read-only view. * Revoking is instant. There is no cache. Permissions [#permissions] All keys currently carry the same scope (`email_finder credits search_history`). Fine-grained per-key permissions are a planned addition. # Errors (/docs/api/errors) All errors are returned as JSON with an `error` message and the appropriate HTTP status. ```json { "error": "Insufficient credits" } ``` Status codes [#status-codes] | Status | Meaning | | ------ | ---------------------------------------------------------------------------------- | | `400` | Invalid request. Bad role, malformed URL, URL too long, or missing required field. | | `401` | Missing, invalid, or revoked API key. | | `402` | Insufficient credits. Top up in the dashboard. | | `404` | Resource not found, or belongs to a different team. | | `409` | A matching search is already in progress. Poll the existing `searchId`. | | `429` | Rate limit exceeded. Honor the `Retry-After` header. | | `500` | Unexpected server error. Safe to retry after a short delay. | Retry guidance [#retry-guidance] * `429` and `5xx`: safe to retry with backoff. * `400`, `401`, `402`, `404`: the request itself is wrong. Do not retry. * `409`: you have an in-flight search. Poll its status instead of retrying `find-email`. Common mistakes [#common-mistakes] * **Forgetting the `Content-Type: application/json` header**: returns `400 Invalid JSON body`. * **Using a revoked key**: returns `401 Invalid or revoked API key`. Create a new key in the dashboard. * **Submitting a role outside the allowlist**: returns `400` with the list of valid roles. Use exact casing. # Overview (/docs/api) Seven endpoints. Bearer-token auth. HMAC-signed webhooks. Ship your integration in an afternoon. Base URL [#base-url] ``` https://api.decisionmaker.email ``` All endpoints sit under `/api/v1/*` and accept JSON bodies. Every request needs an `Authorization: Bearer ` header. What you get [#what-you-get] * **Email search.** Kick off an AI-powered search for an executive email by domain and role, poll for results. * **Cached results.** Reuse searches across your team without re-spending credits. * **Credit and stats introspection.** Check balances and totals with a single call. * **Webhooks.** Receive a signed HTTP POST when an async search completes or fails, instead of polling. How it fits together [#how-it-fits-together] 1. Create an API key in the [dashboard](/dashboard/api-keys). 2. Call `POST /api/v1/find-email` with a `url` and `role`. You get back a `searchId`. 3. Either poll `POST /api/v1/search-status` or register a webhook endpoint to receive the result asynchronously. 4. When the search completes, you get an array of validated emails with confidence scores. Next [#next] * [Quick start](/docs/api/quickstart): curl, Node, Python examples. * [Authentication](/docs/api/authentication): how API keys work. * [Endpoints](/docs/api/endpoints): full reference. # Quick start (/docs/api/quickstart) Create an API key, drop it in an env var, and make your first call. `find-email` kicks off an async search and returns a `searchId` you poll for results. Get an API key [#get-an-api-key] Open the [API Keys dashboard](/dashboard/api-keys), click **New key**, give it a name, and copy the full `dm_live_…` key. You will only see the full key once. First call [#first-call] curl [#curl] ```bash curl -X POST https://api.decisionmaker.email/api/v1/find-email \ -H "Authorization: Bearer dm_live_..." \ -H "Content-Type: application/json" \ -d '{"url":"acme.com","role":"CEO"}' ``` Response: ```json { "searchId": "k1709..." } ``` Node [#node] ```ts const res = await fetch( "https://api.decisionmaker.email/api/v1/find-email", { method: "POST", headers: { Authorization: `Bearer ${process.env.DME_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ url: "acme.com", role: "CEO" }), }, ); const { searchId } = await res.json(); ``` Python [#python] ```python import os, requests res = requests.post( "https://api.decisionmaker.email/api/v1/find-email", headers={ "Authorization": f"Bearer {os.environ['DME_API_KEY']}", "Content-Type": "application/json", }, json={"url": "acme.com", "role": "CEO"}, ) search_id = res.json()["searchId"] ``` Poll for results [#poll-for-results] ```bash curl -X POST https://api.decisionmaker.email/api/v1/search-status \ -H "Authorization: Bearer dm_live_..." \ -H "Content-Type: application/json" \ -d '{"searchId":"k1709..."}' ``` Keep polling every few seconds until `status` is `completed` or `failed`. A typical search takes under 30 seconds. Prefer push over poll? See [Webhooks](/docs/api/webhooks). # Rate limits (/docs/api/rate-limits) Rate limits are enforced per API key using a token-bucket algorithm. Bursts are allowed up to `capacity`, sustained use is limited by `rate`. Limits [#limits] | Operation | Rate | Burst capacity | | --------------- | --------- | -------------- | | `find-email` | 30 / min | 60 | | All other reads | 120 / min | 240 | "All other reads" covers `search-status`, `search-history`, `results-by-domain`, `unique-emails`, `credits`, and `stats`. How token buckets work [#how-token-buckets-work] You start with the full `capacity` of tokens. Every call spends one. Tokens refill at `rate` per minute. When the bucket is empty, you get `429 Too Many Requests`. Example: at 30/min for `find-email` with capacity 60, you can fire 60 requests instantly. Then you refill at one request every 2 seconds. If you stop calling for two minutes, you're back to 60 tokens. Handling 429 [#handling-429] Every 429 response includes a `Retry-After` header in seconds. ``` HTTP/2 429 retry-after: 12 content-type: application/json { "error": "Rate limit exceeded" } ``` Sleep for at least that many seconds before retrying. Most HTTP clients honor `Retry-After` automatically. Upgrading [#upgrading] Need higher limits? Reach out and we'll tune the buckets for your account. # Webhooks (/docs/api/webhooks) Subscribe to `search.completed` and `search.failed` events to skip polling. Every delivery is signed with HMAC-SHA256 so you can verify it came from us. Setting up an endpoint [#setting-up-an-endpoint] Open the [API Keys dashboard](/dashboard/api-keys), scroll to the Webhooks card, click **Add endpoint**, and enter your URL. You will see the signing secret once. Store it in your server's env vars. Your endpoint must return a 2xx response within 10 seconds. Non-2xx or timeout triggers a retry. Payload shape [#payload-shape] ```json { "event": "search.completed", "timestamp": 1776709919, "data": { "searchId": "k1710dek6438k67zhfxqt9ay3d8576da", "teamId": "kx76fgaysm8b9kqsgjczj8ys9d839b6y", "domain": "acme.com", "role": "CEO", "status": "completed", "results": [ { "email": "jane@acme.com", "name": "Jane Doe", "title": "Chief Executive Officer", "score": 95, "source": "verified" } ] } } ``` Headers [#headers] | Header | Example | Purpose | | ----------------- | ---------------------------------- | ----------------------------------------------------- | | `X-DME-Event` | `search.completed` | Which event fired. | | `X-DME-Delivery` | `mn776zwave3z9p7ncdcta4ywph856e9m` | Unique ID for this delivery attempt. Idempotency key. | | `X-DME-Signature` | `t=1776709919,v1=982232...` | Timestamp and HMAC-SHA256 hex signature. | | `Content-Type` | `application/json` | | | `User-Agent` | `decisionmaker.email-webhooks/1.0` | | Verifying the signature [#verifying-the-signature] The signing input is `\n`. Compute HMAC-SHA256 with your secret and compare with a timing-safe equal. Node [#node] ```ts import crypto from "node:crypto"; export function verify(req, secret) { const header = req.headers["x-dme-signature"]; // "t=...,v1=..." const parts = Object.fromEntries( header.split(",").map((kv) => kv.split("=")), ); const signingInput = `${parts.t}\n${req.rawBody}`; const expected = crypto .createHmac("sha256", secret) .update(signingInput) .digest("hex"); return crypto.timingSafeEqual( Buffer.from(expected), Buffer.from(parts.v1), ); } ``` Python [#python] ```python import hmac, hashlib def verify(headers, raw_body, secret): header = headers["x-dme-signature"] # "t=...,v1=..." parts = dict(kv.split("=", 1) for kv in header.split(",")) signing_input = f"{parts['t']}\n{raw_body}" expected = hmac.new( secret.encode(), signing_input.encode(), hashlib.sha256, ).hexdigest() return hmac.compare_digest(expected, parts["v1"]) ``` Always use the raw request body, not a re-serialized JSON object. Any whitespace difference will break the signature. Retries [#retries] Failed deliveries retry with exponential backoff: | Attempt | Delay | | ------- | ----- | | 2 | 60s | | 3 | 120s | | 4 | 240s | | 5 | 480s | | 6 | 960s | After 6 attempts, the delivery is marked failed and dropped. Auto-disable [#auto-disable] If an endpoint accumulates 20 consecutive failed deliveries, it is automatically disabled. Delete and recreate to re-enable. Replay protection [#replay-protection] The `timestamp` is part of the signing input, so an attacker cannot replay an old payload with a new timestamp. Still, reject deliveries with a timestamp older than 5 minutes on your side to limit the replay window if a secret leaks. # credits (/docs/api/endpoints/credits) `POST /api/v1/credits` Your team's current credit balance. **Request** ```json {} ``` **Response** ```json { "balance": 87, "totalPurchased": 100, "totalUsed": 13 } ``` # find-email (/docs/api/endpoints/find-email) `POST /api/v1/find-email` Start an AI-powered search for an executive email at a domain. Returns a `searchId` you poll until the search completes. **Request** ```json { "url": "acme.com", "role": "CEO", "forceRefresh": false } ``` **Response** ```json { "searchId": "k1709..." } ``` `forceRefresh` skips the team's cache and always runs a fresh search (still costs 1 credit). Credit cost: 1 credit per fresh search. Refunded on failure or zero results. Cached searches are free. Supported roles [#supported-roles] `role` is case-sensitive and must be exactly one of: | Value | Title | | ------------------- | ------------------------- | | `CEO` | Chief Executive Officer | | `CTO` | Chief Technology Officer | | `COO` | Chief Operating Officer | | `CFO` | Chief Financial Officer | | `CMO` | Chief Marketing Officer | | `CIO` | Chief Information Officer | | `CPO` | Chief Product Officer | | `CoS` | Chief of Staff | | `Founder` | Founder | | `Co-Founder` | Co-Founder | | `President` | President | | `Managing Director` | Managing Director | | `VP of Sales` | VP of Sales | | `VP of Engineering` | VP of Engineering | | `VP of Marketing` | VP of Marketing | | `SVP` | Senior Vice President | | `EVP` | Executive Vice President | Submitting any other value returns `400` with a JSON error listing the valid roles. # Overview (/docs/api/endpoints) Base URL: `https://api.decisionmaker.email` Every endpoint is `POST` and expects `Authorization: Bearer ` plus a JSON body. | Endpoint | Purpose | | -------------------------------------------------------------------- | ----------------------------------------------------- | | [`/api/v1/find-email`](/docs/api/endpoints/find-email) | Start an AI-powered search for an executive email. | | [`/api/v1/search-status`](/docs/api/endpoints/search-status) | Poll a search by ID. | | [`/api/v1/search-history`](/docs/api/endpoints/search-history) | List recent searches on your team. | | [`/api/v1/results-by-domain`](/docs/api/endpoints/results-by-domain) | Cached results for a single domain. | | [`/api/v1/unique-emails`](/docs/api/endpoints/unique-emails) | Deduplicated list of every email your team has found. | | [`/api/v1/credits`](/docs/api/endpoints/credits) | Current credit balance. | | [`/api/v1/stats`](/docs/api/endpoints/stats) | Aggregate team stats in one call. | # results-by-domain (/docs/api/endpoints/results-by-domain) `POST /api/v1/results-by-domain` Deduplicated results for a single domain across past searches. **Request** ```json { "domain": "acme.com" } ``` **Response** ```json { "results": [ { "email": "jane@acme.com", "name": "Jane Doe", "title": "Chief Executive Officer", "role": "CEO", "isExact": true, "foundAt": 1776000000000 } ] } ``` # search-history (/docs/api/endpoints/search-history) `POST /api/v1/search-history` List recent searches for your team, paginated. **Request** ```json { "limit": 10, "cursor": "..." } ``` **Response** ```json { "searches": [/* ... */], "nextCursor": "...", "isDone": false } ``` `limit` is clamped to 1 to 50 (default 10). Pass the `nextCursor` back in subsequent calls to paginate. # search-status (/docs/api/endpoints/search-status) `POST /api/v1/search-status` Poll a search. Returns results once `status` is `completed`. **Request** ```json { "searchId": "k1709..." } ``` **Response** ```json { "status": "completed", "results": [ { "email": "jane@acme.com", "name": "Jane Doe", "title": "Chief Executive Officer", "score": 95, "source": "verified" } ], "creditsUsed": 0 } ``` `status` progresses through `pending` → `searching` → `validating` → `completed` or `failed`. Prefer push over poll? See [Webhooks](/docs/api/webhooks). # stats (/docs/api/endpoints/stats) `POST /api/v1/stats` Aggregate team stats in a single call. Useful for status bars and admin dashboards. **Request** ```json {} ``` **Response** ```json { "searchCount": 42, "emailCount": 137, "credits": { "balance": 87, "totalPurchased": 100, "totalUsed": 13 } } ``` # unique-emails (/docs/api/endpoints/unique-emails) `POST /api/v1/unique-emails` Every unique email your team has found, across all searches. **Request** ```json { "limit": 20, "cursor": "..." } ``` **Response** ```json { "emails": [ { "email": "jane@acme.com", "name": "Jane Doe", "title": "Chief Executive Officer", "domain": "acme.com", "role": "CEO", "foundAt": 1776000000000 } ], "nextCursor": null, "isDone": true } ``` `limit` is clamped to 1 to 50 (default 20). # Overview (/docs/api/mcp) DecisionMaker.Email ships a [Model Context Protocol](https://modelcontextprotocol.io) server at `mcp.decisionmaker.email/mcp`. Connect it to Claude, Cursor, Claude Code, or any MCP-compatible AI client and look up executive emails without leaving the chat. What you get [#what-you-get] * **No API key management.** The client authorizes via OAuth 2.1. Sign in once with the same account you use on decisionmaker.email. * **Seven tools exposed to the model.** Find email, check search status, list history, get cached results, check credits and stats. * **Team-scoped.** Everything respects your team, credit balance, and plan. * **No extra cost.** Credits are deducted the same way as direct API use. How it works [#how-it-works] 1. Add the MCP URL (`https://mcp.decisionmaker.email/mcp`) to your AI client. 2. The client pops an OAuth consent screen. Sign in and approve. 3. The model can now call `dm_find_email` and the other six tools as needed. Comparison with the API [#comparison-with-the-api] Both paths talk to the same Convex backend and share credits, rate limits, and search history. | | MCP | HTTP API | | ----------- | ------------------------------------------- | ---------------------------------- | | Auth | OAuth 2.1 (browser sign-in) | Bearer API key | | Who uses it | AI assistants (Claude, Cursor, Claude Code) | Your code, scripts, integrations | | URL | `mcp.decisionmaker.email/mcp` | `api.decisionmaker.email/api/v1/*` | | Webhooks | Not applicable (synchronous tool calls) | Supported | Next [#next] * [Setup](/docs/mcp/setup): add the server to your AI client. * [Tools](/docs/mcp/tools): full list of what the model can call. # Setup (/docs/api/mcp/setup) The MCP server URL is the same everywhere: `https://mcp.decisionmaker.email/mcp` The first time you connect, your client will open a browser window for OAuth consent. Sign in with the same account you use on decisionmaker.email and approve access. Claude (web and desktop) [#claude-web-and-desktop] 1. Open Claude. 2. Go to **Settings** → **Connectors** → **Add Custom Connector**. 3. Paste `https://mcp.decisionmaker.email/mcp`. 4. Click **Connect**. Approve in the OAuth popup. Claude Code [#claude-code] Claude Code reads `.mcp.json` at your project root. Create it: ```json { "mcpServers": { "decisionmaker-email": { "type": "http", "url": "https://mcp.decisionmaker.email/mcp" } } } ``` Then run `/mcp` inside Claude Code to authorize. Other clients [#other-clients] Any client that supports the Streamable HTTP transport and OAuth 2.1 Dynamic Client Registration will work. Point it at `https://mcp.decisionmaker.email/mcp` and follow the client's generic "add custom MCP server" instructions. # Tools (/docs/api/mcp/tools) Seven tools are exposed over MCP. The model decides when to call them based on the user's request. All tools are team-scoped. dm_find_email [#dm_find_email] Find an executive email at a company. Starts an AI-powered search, waits for completion, returns validated results. **Inputs** * `url` (string): company domain or URL * `role` (string): one of 17 supported executive roles, see [Supported roles](/docs/api/endpoints/find-email#supported-roles) * `forceRefresh` (boolean, optional): skip the team cache and run a fresh search **Credit cost**: 1 credit. Refunded on failure or zero results. Cached searches are free. `forceRefresh: true` always costs 1 credit. dm_get_search_status [#dm_get_search_status] Check the current status of a specific email search by ID. **Inputs** * `searchId` (string) **Credit cost**: free. dm_search_history [#dm_search_history] View recent email search history for the team. **Inputs** * `limit` (number, default 10, max 50) **Credit cost**: free. dm_get_results_by_domain [#dm_get_results_by_domain] Get all previously found emails for a specific company domain. **Inputs** * `domain` (string) **Credit cost**: free. dm_get_credits [#dm_get_credits] Check the team's credit balance. **Inputs**: none. **Credit cost**: free. dm_get_unique_emails [#dm_get_unique_emails] List all unique email addresses found across past searches on the team. **Inputs** * `limit` (number, default 20, max 50) **Credit cost**: free. dm_get_stats [#dm_get_stats] Team stats in a single call: total searches, total unique emails found, and current credit balance. **Inputs**: none. **Credit cost**: free.