SendHQ
Send and receive email from your verified domains; manage domains, templates and deliverability.
Documentation
SendHQ MCP server
Give an AI agent full, safe control of one SendHQ workspace through 59 strictly typed MCP tools. Local stdio server: sendhq mcp.
curl -fsSL https://downloads.sendhq.cc/install.sh | sh
claude mcp add sendhq --env SENDHQ_API_KEY=re_your_key -- sendhq mcp
What this server is
The SendHQ MCP server lets an AI agent operate one SendHQ workspace through the Model Context Protocol: send email (single, batch, templated, replies, attachments, idempotent retries), read and search sent and received mail (subjects, bodies, and attachment names) and its delivery events, organize mail into labels with auto-filing rules, manage drafts and private attachments, author and publish hosted templates, add and verify domains and their DNS, set up inbound receiving and inbound addresses, inspect deliverability, bounces, complaints, and suppressions, and read account usage, billing state, analytics, and API-key metadata.
It is a local stdio server built into the sendhq CLI binary. Your MCP client starts sendhq mcp as a child process and speaks JSON-RPC over stdin/stdout. Every tool call becomes one documented request to the SendHQ REST API at https://sendhq.cc/api/v1 authenticated with your workspace API key, so the MCP server has exactly the permissions of that key and no more.
- 59 tools in 8 groups, generated from one catalog that is also published as tools.json.
- Strict JSON Schemas: unknown arguments, wrong types, and missing required fields are rejected locally before anything reaches SendHQ.
- Structured errors with a stable
code, the HTTPstatus, anexplanation, a concreteremedy, and whether retrying can help. - Every tool that sends real email or destroys data says so in the first words of its description and carries MCP safety annotations.
--read-onlymode hides every sending and mutating tool.- Nothing is logged. stdout carries only protocol messages; the API key and message content never reach a log.
Not the documentation MCP endpoint. SendHQ also hosts a small read-only documentation MCP endpoint at
https://sendhq.cc/api/mcp(pricing and docs lookup, no account access). The server on this page is the full, account-scoped one; it runs locally or as the hosted connector below.
Use SendHQ in Claude and ChatGPT
No install needed: SendHQ also runs this server as a hosted connector at https://mcp.sendhq.cc/mcp with the same tools. You sign in with your SendHQ account instead of pasting a key.
Claude
- Open Settings → Connectors and find SendHQ in the directory, or choose Add custom connector and paste
https://mcp.sendhq.cc/mcp. - Click Connect, sign in to SendHQ, review the access and click Allow.
- Ask Claude to check your inbox, send an email from your verified domain, or explain a bounce.
ChatGPT
- Open Settings → Security and login and turn on Developer mode.
- Go to chatgpt.com/plugins, click Create MCP app, name it SendHQ and enter
https://mcp.sendhq.cc/mcp. - Sign in to SendHQ and click Allow, then pick SendHQ from the tools menu in a new chat.
Muse by Meta
In Muse, open Connectors and search for SendHQ. Click Connect, sign in to SendHQ and click Allow.
Approval and disconnecting
- The
request_featuretool sends a feature request to the SendHQ team with your account details, so we can follow up by email. - Tools that send real email or delete data are labelled as such. Whether the assistant asks you first is set per tool in the assistant: in Claude, choose Needs approval for those tools under Settings → Connectors → SendHQ.
- The connector gets its own API key, named after the assistant (for example “Claude (AI connector)”). Delete it under API Keys to disconnect immediately.
- It cannot create or revoke API keys or change billing. Attachments are sent and returned as base64; there is no local file access.
- Unpaid workspaces (integration trial) can deliver only to the account email or an AWS SES simulator address.
Questions: postmaster@sendhq.cc. Privacy: sendhq.cc/privacy.
Install
Install the sendhq binary (Linux, macOS, and Windows on x86-64 and arm64). The installer verifies the release checksum and puts the binary in ~/.local/bin by default.
macOS and Linux:
curl -fsSL https://downloads.sendhq.cc/install.sh | sh
Windows PowerShell:
irm https://downloads.sendhq.cc/install.ps1 | iex
Check the install:
sendhq version
SENDHQ_API_KEY=re_your_key sendhq doctor
Create an API key in the dashboard at https://sendhq.cc/app#/keys. The MCP server cannot create keys. The one command that runs the server is:
Run the stdio server:
SENDHQ_API_KEY=re_your_key sendhq mcp
You normally never run that by hand: the MCP client launches it. When run in a terminal it waits for JSON-RPC on stdin.
Configure your client
Claude Code
claude mcp add:
claude mcp add sendhq --env SENDHQ_API_KEY=re_your_key -- sendhq mcp
# read-only variant
claude mcp add sendhq-readonly --env SENDHQ_API_KEY=re_your_key -- sendhq mcp --read-only
Add --scope user to make it available in every project, or --scope project to write it to the project's .mcp.json. For a shared .mcp.json, reference the key from the environment instead of committing it; Claude Code expands ${VAR} in .mcp.json.
.mcp.json:
{
"mcpServers": {
"sendhq": {
"command": "sendhq",
"args": [
"mcp"
],
"env": {
"SENDHQ_API_KEY": "${SENDHQ_API_KEY}"
}
}
}
}
OpenAI Codex
~/.codex/config.toml:
[mcp_servers.sendhq]
command = "sendhq"
args = ["mcp"]
env = { SENDHQ_API_KEY = "re_your_key" }
Or from the command line: codex mcp add sendhq --env SENDHQ_API_KEY=re_your_key -- sendhq mcp.
Claude Desktop
Edit claude_desktop_config.json (macOS: ~/Library/Application Support/Claude/, Windows: %APPDATA%\Claude\) and restart the app. Desktop apps do not inherit your shell PATH, so use the absolute path of the binary (which sendhq).
claude_desktop_config.json:
{
"mcpServers": {
"sendhq": {
"command": "/Users/you/.local/bin/sendhq",
"args": [
"mcp"
],
"env": {
"SENDHQ_API_KEY": "re_your_key"
}
}
}
}
Any other MCP client
Configure a stdio server with command sendhq, arguments ["mcp"] (optionally "--read-only"), and the environment variables below. The server supports MCP protocol versions 2024-11-05, 2025-03-26, 2025-06-18, and 2025-11-25, and implements initialize, ping, tools/list, and tools/call. Tool results carry both a JSON text block and structuredContent.
Raw stdio smoke test (pipe into sendhq mcp):
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"1"}}}
{"jsonrpc":"2.0","method":"notifications/initialized"}
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get_service_health","arguments":{}}}
There is no hosted HTTP transport for the account-scoped server. A remote, write-capable MCP endpoint would need per-user OAuth, which SendHQ does not offer; the local binary keeps the key on the machine that already holds it.
Environment and flags
| Variable or flag | Required | Meaning |
|---|---|---|
SENDHQ_API_KEY | yes | Workspace API key (re_…). Every tool except get_service_health needs it. Without it the server still starts and every call returns a structured auth_error explaining how to fix it. |
SENDHQ_API_BASE_URL | no | API base URL. Default https://sendhq.cc/api/v1. Use it only for a local or staging deployment. SENDHQ_BASE_URL is accepted as an older alias. |
SENDHQ_MCP_READ_ONLY | no | 1, true, or yes behaves like --read-only. |
--read-only | no | Expose only tools that neither send email nor change state. Hidden tools are also refused if called by name. |
SENDHQ_PROFILE / --profile | no | Use a key stored by sendhq auth login in the OS keyring instead of SENDHQ_API_KEY. The environment variable wins when both exist. |
The key is sent only as the Authorization: Bearer header to the configured base URL. It is never printed, logged, echoed in errors, or included in tool results.
Safety model for agents
- Sends real email.
send_email,send_batch, andsend_template_testdeliver mail to real people and consume delivery credits. Their descriptions start withSENDS REAL EMAIL. Call them only when the user explicitly asked for that specific message to be sent, with the recipients, sender, and content confirmed. - Destructive.
delete_email,delete_draft,delete_attachment,delete_domain,delete_inbox, andremove_suppressionare markeddestructiveHint: trueand their descriptions start withDESTRUCTIVE. Confirm with the user first.remove_suppressionweakens a safety block and is appropriate only when a human confirms the address works again. - Changes state. Creating or updating drafts, templates, domains, and inboxes, publishing templates, and starting verification change the workspace but do not send mail.
- Read-only. Everything else is
readOnlyHint: trueand safe to call freely. - DNS is never changed by this server.
add_domainreturns records for a human to publish;get_domain_connect_linkreturns a consent URL that a person must open and approve at their DNS provider. - Billing is never changed by this server.
get_accountreads plan, usage, and subscription state only. - Unpaid workspaces (integration trial) can deliver only to the account owner's email (
get_account→user.email) or an AWS SES simulator address such assuccess@simulator.amazonses.com, and cannot send attachments. - Accepted is not delivered. A successful send returns an ID; delivery, bounce, and complaint evidence arrives later in
list_email_events. Never claim inbox placement or that a person read a message. - Do not rotate to a different From address to get around a
423pause, and never re-add unsubscribed or complained recipients.
API keys are out of scope
By design there are no tools that create, modify, rotate, revoke, or delete API keys. An agent must not mint or destroy credentials. list_api_keys returns only names, non-secret prefixes, and last-used times. Key management stays in the dashboard with a signed-in human.
Workflows
1. First send
get_service_healthconfirms the API is reachable (works without a key).get_accountshows the plan (access.tier), remaining quota, anduser.email. On the trial, that email is the only real recipient allowed.list_sending_identitieslists From addresses you can use. If it is empty, do the domain workflow first.- Confirm sender, recipient, subject, and body with the user, then
send_emailwith anidempotency_key. list_email_eventswith the returnedidshowsdelivery,bounce,complaint, orrejectonce the provider reports it (usually seconds to minutes).
First send:
{
"name": "send_email",
"arguments": {
"from": "Acme <hello@example.com>",
"to": [
"owner@example.com"
],
"subject": "SendHQ is connected",
"text": "It works.",
"idempotency_key": "first-send-2026-09-26"
}
}
2. Domain verification end to end
add_domainwithname: "example.com". The result includes the DNS records (DKIM CNAMEs, SES verification, SPF, recommended DMARC).get_dns_providerwith thedomain_iddetects the authoritative DNS provider and returns the exact relative host to enter for each record at that provider.- If
providers.domainConnect.availableis true,get_domain_connect_linkreturns a consent URL. Give it to the human; nothing changes until they approve at the provider. Otherwise, give the human the records to publish. Never publish a second SPF record: mergeinclude:amazonses.cominto the existingv=spf1value. verify_domainrechecks DNS and SES. Status moves throughpending,checking, andpropagatingtoverified. Pollverify_domainorget_domainevery 30–60 seconds; DNS can take minutes to hours.- When
statusisverified, the domain's addresses appear inlist_sending_identities.
3. Bounces, complaints, and suppressions
list_blocked_recipientsreturns every blocked address with its reason (bounce,complaint,unsubscribe) and a summary count.list_suppressionsreturns hard-bounce and complaint suppressions;deliverability_statsgives 30-day delivery, bounce, and complaint rates;list_sender_reputationshows which From addresses are throttled or paused.- A send containing a suppressed recipient fails with
422 recipient_suppressed. Remove that recipient and send again. - Only when a human confirms a bounced mailbox now works, call
remove_suppression. Complaint suppressions are permanent (409 complaint_suppression_locked).
4. Receive inbound email
- The domain (often a subdomain such as
inbound.example.com) must be verified. setup_inboundprovisions receiving and returns one MX record. A human publishes it.verify_inbounduntilstatusisready.create_inboxwithdomain_idandlocal_part(for examplesupport) createssupport@inbound.example.com.- Poll
list_emailswithdirection: "in"andunread: true(optionallyinbox_id). Read a message withget_email, its conversation withget_thread, attachments withdownload_attachment, and mark it handled withmark_email(read: true). - Reply in-thread with
send_emailandreply_to_email_id; SendHQ sets In-Reply-To, References, and the thread.
5. Webhooks and event notifications
SendHQ does not currently offer customer-configurable webhooks, so there is no webhook tool. Provider notifications are processed inside SendHQ and exposed through reads. Poll instead: list_email_events for one message's outcome, list_emails with status (for example bounced) or after for recent changes, list_emails with direction: "in" and unread: true for new inbound mail, and list_blocked_recipients for new suppressions. Poll no more than about once a minute per question.
6. Diagnose a delivery failure
- Find the message:
list_emailswithdirection: "out"andtoorquery, orget_emailif you have the ID.status: failedmeans SendHQ or the provider rejected it at submission; the email's error explains why. list_email_events:bounce(permanent or transient, with the provider diagnostic),complaint,reject, ordelivery. No events yet means the provider has not reported; wait and check again.- If the send call itself failed, read the error
code:sender_domain_unverified→ finish domain verification;recipient_suppressed→ the address hard-bounced or complained before;sender_paused→ inspectlist_sender_reputationand fix the list source;trial_recipient_restricted→ trial limits;quota_exhausted→get_accountusage. get_domainchecks that DKIM, SPF, and DMARC are still published;deliverability_statsshows whether the problem is one message or a trend.- Report what the evidence shows. A
deliveryevent means the recipient's server accepted the message, not that it reached the inbox or was read.
7. Own a task bucket (labels)
create_labelwithname(for exampleAgent/Orders) andskip_inbox: true. That makes the label a bucket: received mail that gets it is archived, so it appears only in the label, never the human's Inbox.- Send task mail with
send_email(orsend_batch) andlabels: ["Agent/Orders"]. Replies to that conversation inherit the label automatically and skip the Inbox. - For mail that starts outside your conversations, add a filing rule:
create_label_rulewithinbox_id(a dedicated address such asorders@…),from,to, orsubject. Passapply_to_existing: trueto file mail already received. - Work the bucket:
list_emailswithlabel: "Agent/Orders",direction: "in", andunread: true; read withget_emailorget_thread, reply withsend_emailandreply_to_email_id, andmark_emailread: truewhen handled. - Move a stray message in or out with
label_email(add/remove). Adding a bucket label to a received message also archives it. - Optionally
set_inbox_forwardingsends a copy of everything a receiving address gets to another mailbox (the target confirms by email first).
Send into a bucket:
{
"name": "send_email",
"arguments": {
"from": "Orders <orders@example.com>",
"to": [
"customer@example.net"
],
"subject": "Order 1042: confirm delivery window",
"text": "Reply with a time that works.",
"labels": [
"Agent/Orders"
],
"idempotency_key": "order-1042-window"
}
}
8. Attachments and templates
Attach up to 10 files with send_email attachments (each needs content_base64 or a local file_path; filename defaults to the file's basename) on a paid plan. For hosted templates: create_template → update_template_draft → render_template to preview with sample data → send_template_test (sends one real test) → publish_template, then send with send_email or send_batch using template: {key, data} and exactly one to recipient.
Results, pagination, and errors
A successful call returns the API's JSON object as structuredContent and as a JSON text block. Every list_* tool accepts limit (1–200, default 50) and offset, and adds a pagination object. Keep calling with offset: pagination.next_offset while has_more is true.
Paginated result:
{
"data": [
"…"
],
"count": 50,
"pagination": {
"offset": 0,
"limit": 50,
"returned": 50,
"total": 180,
"has_more": true,
"next_offset": 50
}
}
A failed call returns isError: true with a structured error. Follow remedy instead of retrying blindly; only retry when retryable is true.
Structured tool error:
{
"error": {
"code": "trial_recipient_restricted",
"status": 402,
"message": "The integration trial can deliver only to your account email or an AWS SES simulator address",
"retryable": false,
"explanation": "This workspace is on the unpaid integration trial. Trial sends can be delivered only to the account owner's email address or an AWS SES simulator address.",
"remedy": "Send to the account email (get_account -> user.email) or a simulator address such as success@simulator.amazonses.com to test. To email anyone else, the account owner must activate a paid plan in the dashboard (Profile & Billing). Do not retry the same recipients."
}
}
Optional error fields: request_id (quote it to support), retry_after_seconds, problems (list of schema violations for invalid_arguments), and idempotent_replayed (see Idempotency).
Idempotency
send_email and send_batch accept idempotency_key (max 200 characters), sent as the Idempotency-Key header. Generate one stable key per logical message, for example invoice-4812-receipt.
- A retry must reuse the same key AND an identical request body. Same key with any change (recipient, subject, body, header, template data, even argument values) returns
409 idempotency_conflict. - Same key, same body, original finished: SendHQ returns the stored result without sending again. This is how you safely retry after a timeout or
network_error. - Same key while the original is still running:
409 idempotency_in_progress, retryable after a short wait. - A new logical message needs a new key.
- Stored failures replay too. If the first attempt failed, retrying with the same key returns that same failure with
idempotent_replayed: trueandretryable: false. Checklist_emails(direction: out) to confirm nothing went out, fix the cause, then send with a new key. - The server never retries a POST on its own. Only read-only GET calls are retried automatically (up to 3 attempts on network errors, 429, and 5xx).
send_emailwith inlineattachmentscannot take anidempotency_key, because it runs several requests. For retry-safe attachment sends:create_draft→upload_attachment→send_emailwithdraft_idandidempotency_key.
Retry-safe send (repeat exactly on timeout):
{
"name": "send_email",
"arguments": {
"from": "Acme <billing@example.com>",
"to": [
"owner@example.com"
],
"subject": "Receipt #4812",
"text": "Thanks for your payment.",
"idempotency_key": "receipt-4812"
}
}
Rate limits and quotas
SendHQ does not publish a fixed requests-per-second limit for the API. The limits an agent actually meets are usage limits, returned as 429:
- Monthly recipient deliveries per plan. Each To, Cc, and Bcc address counts as one delivery. See
get_account→usage.recipientDeliveriesvsusage.emailQuotaMonth. - Daily recipients per exact From address, set by that sender's reputation state (
list_sender_reputation→dailyLimit, 2,000 by default on paid plans). - Integration trial: 100 recipients in total, only to the account email or SES simulator addresses.
- Attachments: at most 10 files and 10 MB per message; 10 GB of recipient-weighted attachment transfer per month on paid plans.
- Per request: To + Cc + Bcc up to 100 addresses;
send_batchup to 100 messages. - Reputation circuit breaker: in a rolling 7-day window, bounces or complaints above threshold throttle or pause one From address (
423 sender_paused). It recovers automatically once rates fall.
quota_exhausted is not retryable until the period resets or the plan changes. rate_limited is retryable after retry_after_seconds; for sends, retry with the same idempotency_key and identical body.
Error catalogue
code is stable; branch on it rather than on message.
| code | HTTP | Retry? | What it means and what to do |
|---|---|---|---|
invalid_arguments | — | no | Arguments failed the tool's JSON Schema locally; nothing reached SendHQ. Fix the fields listed in problems. |
auth_error | 401 | no | Missing, revoked, or wrong API key. Set SENDHQ_API_KEY for the server process; a human creates keys in the dashboard. |
trial_recipient_restricted | 402 | no | Integration trial can deliver only to the account email or an SES simulator address. Send there, or the owner activates a paid plan. |
payment_required | 402 | no | Feature needs a paid plan (for example attachments). Send without it or upgrade. |
sender_domain_not_owned | 403 | no | From domain is not in this workspace. Use list_sending_identities or add_domain. |
sender_domain_unverified | 403 | no | From domain is not verified yet. get_domain, publish missing records, verify_domain. |
domain_limit_reached | 403 | no | Plan domain limit reached. Remove an unused domain (with approval) or upgrade. |
marketing_not_enabled | 403 | no | Marketing class is not enabled for this domain or plan. Use transactional only if the message genuinely is. |
forbidden | 403 | no | Policy does not allow the operation. Adjust the request. |
not_found | 404 | no | ID is not in this workspace. List the resource to find the right ID; restore archived templates first. |
idempotency_conflict | 409 | no | Key reused with a different body. Resend the exact original, or use a new key for a new message. |
idempotency_in_progress | 409 | yes | Original request still running. Wait, then retry with the same key and body. |
revision_conflict | 409 | no | Template draft changed since you read it. get_template, merge, save again. |
complaint_suppression_locked | 409 | no | Recipient complained. Never email them again. |
inbound_not_ready | 409 | no | Inbound receiving not ready. setup_inbound, publish MX, verify_inbound. |
conflict | 409 | no | Resource already exists or is in the wrong state. Read it and adjust. |
attachments_too_large | 413 | no | More than 10 files or 10 MB. Remove or shrink attachments. |
recipient_suppressed | 422 | no | A recipient hard-bounced or complained before. Remove them; see list_blocked_recipients. |
recipient_unsubscribed | 422 | no | A recipient opted out of marketing mail. Remove them permanently. |
validation_failed | 422 | no | Content rejected, for example template data that breaks the variable contract. Fix the input. |
sender_paused | 423 | no | This From address is paused by the 7-day bounce/complaint circuit breaker. Stop, fix the list, wait for automatic recovery. |
quota_exhausted | 429 | no | Monthly, daily per-sender, attachment, or trial limit reached. Check get_account; wait for reset or upgrade. |
rate_limited | 429 | yes | Slow down; wait retry_after_seconds. Sends: same key, same body. |
server_error | 5xx | yes | Temporary SendHQ or provider failure. Back off and retry; sends with the same key and body. If idempotent_replayed is true, use a new key after confirming nothing was sent. |
network_error | — | yes | Request or response lost. Retry; for sends the same idempotency_key makes that safe. |
invalid_request | 400 | no | Malformed request. Read message and correct it. |
tool_error | — | no | Local failure inside the MCP server (for example an unreadable file_path). Read message. |
Tool reference
Every tool with its safety class, the REST endpoint it calls, its parameters, return shape, and an example tools/call params object. Parameters are exact: the server rejects anything not listed.
Emails and threads
send_email
Send one email · Sends real email · POST /emails
SENDS REAL EMAIL. Send one message from a verified domain: raw html/text, a published hosted template, a reply in an existing thread, or a message with attachments. Pass idempotency_key so a retry cannot send twice; a retry must reuse the same key AND an identical request, otherwise SendHQ returns 409. attachments is a convenience that creates a draft, uploads each file, and sends with that draft; it cannot be combined with idempotency_key or draft_id (use create_draft + upload_attachment + send_email with draft_id for retry-safe attachment sends). Unpaid workspaces (integration trial) can deliver only to the account email or an AWS SES simulator address, and cannot send attachments.
Provide at least one of: html, text, template.
| Parameter | Type | Required | Description |
|---|---|---|---|
from | string | yes | Sender, e.g. Acme <hello@example.com>. The domain must be verified in this workspace (see list_sending_identities). (max 998 chars) |
to | string[] | yes | Recipients. Each entry is an address, optionally with a display name. To+cc+bcc may total at most 100; every destination consumes one delivery credit. (1–100 items) |
cc | string[] | no | Carbon-copy recipients. (0–100 items) |
bcc | string[] | no | Blind-copy recipients. (0–100 items) |
subject | string | no | Subject line. Omit when sending a template. (max 998 chars) |
text | string | no | Plain-text body. Provide text, html, or template. |
html | string | no | HTML body. SendHQ sanitizes it and derives text when text is omitted. |
reply_to | string | no | Reply-To address. |
headers | object | no | Extra safe custom headers (string values), e.g. {"X-Entity-Ref-ID": "123"}. Routing headers such as From/To/Message-ID are controlled by SendHQ. |
message_class | string | no | transactional (default) or marketing. Marketing requires a marketing-enabled plan or domain and adds unsubscribe handling. (one of transactional, marketing) |
reply_to_email_id | string | no | Reply inside an existing conversation: the em_… ID of the message being answered. SendHQ sets In-Reply-To/References and the thread. |
thread_id | string | no | Explicit thread ID to file the message under. |
draft_id | string | no | Send a stored draft's attachments with this message (dr_…). The draft is deleted after a successful send. |
template | object | no | Send a published hosted template instead of raw html/text. Requires exactly one to recipient and no cc/bcc; the template supplies the subject. Provide at least one of: id, key. |
template.id | string | no | Template ID (tmpl_…). Provide id or key. |
template.key | string | no | Template key such as account-welcome. Provide id or key. |
template.version_id | string | no | Optional published release ID (tmplv_…). Defaults to the current published release. |
template.data | object | no | Values for the template's typed variables. |
labels | string[] | no | Label names or lbl_… IDs to file this message under. Unknown names are created. Replies in the conversation inherit the labels, and a bucket label (skip_inbox) keeps those replies out of the Inbox. Max 10. (0–10 items) |
idempotency_key | string | no | Idempotency-Key header (max 200 chars). Reuse it only to retry this exact request. (max 200 chars) |
attachments | object[] | no | Files to attach (max 10 files, 10 MB total). Each needs content_base64 (plus filename) or a local file_path. (0–10 items) Provide at least one of: content_base64, file_path. |
attachments[].filename | string | no | File name shown to the recipient. Required with content_base64; defaults to the basename of file_path. (max 255 chars) |
attachments[].content_type | string | no | MIME type, e.g. application/pdf. Defaults to application/octet-stream. |
attachments[].content_base64 | string | no | Standard base64 file content. |
attachments[].file_path | string | no | Absolute path of a local file readable by the MCP server process. |
Returns: {id: em_…, providerMessageId, threadId, templateId, templateVersionId, isTest}. Acceptance is not delivery: follow up with list_email_events.
Example:
{
"name": "send_email",
"arguments": {
"from": "Acme <hello@example.com>",
"to": [
"owner@example.com"
],
"subject": "Your export is ready",
"text": "Download it from your dashboard.",
"idempotency_key": "export-ready-42"
}
}
send_batch
Send a batch of individualized emails · Sends real email · POST /emails/batch
SENDS REAL EMAIL. Send 1–100 independent messages in one request (use this for per-recipient template personalization). Each item has the same shape as send_email (without attachments/idempotency_key). Items succeed or fail individually: HTTP 207 means partial success; inspect each data[i].ok and data[i].error. One idempotency_key covers the whole batch body.
| Parameter | Type | Required | Description |
|---|---|---|---|
emails | object[] | yes | Messages to send. (1–100 items) Provide at least one of: html, text, template. |
idempotency_key | string | no | Idempotency-Key for the entire batch (max 200 chars). (max 200 chars) |
Returns: {data: [{index, ok, id?, error?: {message, status}}], count, successful, failed}.
Example:
{
"name": "send_batch",
"arguments": {
"emails": [
{
"from": "Acme <hello@example.com>",
"to": [
"owner@example.com"
],
"template": {
"key": "account-welcome",
"data": {
"first_name": "Asha"
}
}
}
],
"idempotency_key": "welcome-batch-2026-09-26"
}
}
list_emails
List and search email · Read-only · GET /emails
List sent (direction: out) and received (direction: in) email newest-first with filters. Received mail is classified: read the human inbox with direction: in, archived: false, category: primary; triage with important: true; spam is hidden unless category: spam or include_spam: true. Paginated: the result includes pagination {offset, limit, returned, total?, has_more, next_offset}.
| Parameter | Type | Required | Description |
|---|---|---|---|
direction | string | no | in for received, out for sent. (one of in, out) |
status | string | no | Status filter, e.g. queued, sent, delivered, bounced, complained, failed. |
domain | string | no | Only messages for this domain, or a comma-separated list of domains (matches any). |
inbox_id | string | no | Only messages received by this inbox (inb_…). |
label | string | no | Only messages carrying this label: a label ID lbl_… or exact name, or a comma-separated list (matches any). Use list_labels to see folders. |
archived | boolean | no | false = the Inbox view (received mail not archived), true = archived only. Omit for all mail. |
category | string | no | primary (people), updates (newsletters, bulk, automated), or spam; or a comma-separated list. Spam is hidden unless requested. |
important | boolean | no | true = only messages flagged important (replies to conversations you started, and senders marked important). |
include_spam | boolean | no | Include spam in the results (for searches across every folder). |
from | string | no | Sender address contains this value. |
to | string | no | Recipient address contains this value. |
unread | boolean | no | true = unread only, false = read only. |
after | string | no | ISO-8601 timestamp; only messages created after it. (date-time) |
before | string | no | ISO-8601 timestamp; only messages created before it. (date-time) |
query | string | no | Free-text search over subjects, bodies, sender/recipient addresses, and attachment filenames. (max 200 chars) |
limit | integer | no | Page size. Defaults to 50. (default 50; 1–200) |
offset | integer | no | Number of records to skip. Use pagination.next_offset from the previous page. (default 0; 0–…) |
Returns: {data: [email summaries], count, pagination}.
Example:
{
"name": "list_emails",
"arguments": {
"direction": "in",
"unread": true,
"limit": 25
}
}
get_email
Get one email · Read-only · GET /emails/:email_id
Retrieve one message with headers, html/text body, status, thread metadata, and attachment metadata (download bytes with download_attachment).
| Parameter | Type | Required | Description |
|---|---|---|---|
email_id | string | yes | Email ID (starts with em_), as returned by a list or create tool. (max 128 chars) |
Returns: Email object: {id, direction, status, from, to, cc, bcc, subject, html, text, threadId, messageId, providerMessageId, readAt, createdAt, attachments: [{id, filename, contentType, sizeBytes, available}]}.
Example:
{
"name": "get_email",
"arguments": {
"email_id": "em_123"
}
}
mark_email
Mark read, archived, spam, or important · Changes state · PATCH /emails/:email_id
Update one message: read, archived, category (primary, updates, spam; received mail only), and important. Reporting spam or marking important teaches SendHQ about that sender for future mail; pass learn: false to change only this message. Pass at least one field.
| Parameter | Type | Required | Description |
|---|---|---|---|
email_id | string | yes | Email ID (starts with em_), as returned by a list or create tool. (max 128 chars) |
read | boolean | no | true = read, false = unread. |
archived | boolean | no | true = archive (skip the Inbox), false = move back to the Inbox. |
category | string | no | Move a received message to primary, updates, or spam. (one of primary, updates, spam) |
important | boolean | no | Flag or unflag the message as important. |
learn | boolean | no | false = do not remember this verdict for the sender (default true). |
Returns: The updated email object.
Example:
{
"name": "mark_email",
"arguments": {
"email_id": "em_123",
"read": true
}
}
delete_email
Delete an email · Destructive · DELETE /emails/:email_id
DESTRUCTIVE: permanently delete a retained message and its stored attachments from SendHQ. It does not recall a message that was already delivered.
| Parameter | Type | Required | Description |
|---|---|---|---|
email_id | string | yes | Email ID (starts with em_), as returned by a list or create tool. (max 128 chars) |
Returns: {ok: true}.
Example:
{
"name": "delete_email",
"arguments": {
"email_id": "em_123"
}
}
list_email_events
List delivery events for an email · Read-only · GET /emails/:email_id/events
Provider events for one sent message: delivery, bounce, complaint, reject, open, click. This is the evidence for whether a message was delivered or why it failed. Paginated: the result includes pagination {offset, limit, returned, total?, has_more, next_offset}.
| Parameter | Type | Required | Description |
|---|---|---|---|
email_id | string | yes | Email ID (starts with em_), as returned by a list or create tool. (max 128 chars) |
limit | integer | no | Page size. Defaults to 50. (default 50; 1–200) |
offset | integer | no | Number of records to skip. Use pagination.next_offset from the previous page. (default 0; 0–…) |
Returns: {data: [{event_type, recipient, reason, created_at, …}], count, pagination}.
Example:
{
"name": "list_email_events",
"arguments": {
"email_id": "em_123"
}
}
get_thread
Get a conversation · Read-only · GET /threads/:thread_id
Retrieve every message in a conversation in chronological order (sent and received), each with attachment metadata.
| Parameter | Type | Required | Description |
|---|---|---|---|
thread_id | string | yes | Thread ID (usually the em_… ID of the first message; see threadId on any email). (max 128 chars) |
Returns: {id, subject, data: [emails]}.
Example:
{
"name": "get_thread",
"arguments": {
"thread_id": "em_123"
}
}
Labels and auto-filing rules
list_labels
List labels · Read-only · GET /labels
List the workspace's labels (folders) with total and unread counts and their auto-filing rules. Paginated: the result includes pagination {offset, limit, returned, total?, has_more, next_offset}.
| Parameter | Type | Required | Description |
|---|---|---|---|
limit | integer | no | Page size. Defaults to 50. (default 50; 1–200) |
offset | integer | no | Number of records to skip. Use pagination.next_offset from the previous page. (default 0; 0–…) |
Returns: {data: [{id, name, color, totalCount, unreadCount, rules: [...]}], count, pagination}.
Example:
{
"name": "list_labels",
"arguments": {}
}
get_label
Get a label · Read-only · GET /labels/:label_id
Retrieve one label with counts and auto-filing rules.
| Parameter | Type | Required | Description |
|---|---|---|---|
label_id | string | yes | Label ID (starts with lbl_) or the exact label name. (max 128 chars) |
Returns: Label object.
Example:
{
"name": "get_label",
"arguments": {
"label_id": "Billing"
}
}
create_label
Create a label · Changes state · POST /labels
Create a folder-style label. Set skip_inbox: true to make it a bucket an agent owns: send with labels: [name] and the replies are filed into the label and kept out of the Inbox. Optional auto-filing rules file new sent/received mail (every condition on a rule must match). Set apply_to_existing to also file retained mail.
| Parameter | Type | Required | Description |
|---|---|---|---|
name | string | yes | Label name, e.g. Billing or Clients/Acme. Unique per workspace (case-insensitive). (max 64 chars) |
color | string | no | Hex color such as #1a73e8. Optional. |
skip_inbox | boolean | no | Bucket mode: received mail that gets this label (by rule, by replying to a conversation sent with this label, or by hand) is archived so it appears only in the label, not the Inbox. |
rules | object[] | no | Optional auto-filing rules (max 20). Each needs at least one of inbox_id, from, to, subject. (0–20 items) |
rules[].direction | string | no | Only in (received) or out (sent) mail. Omit for both. (one of in, out) |
rules[].inbox_id | string | no | Only mail received by this inbox (inb_…). Files each receiving address into its own folder. |
rules[].from | string | no | Sender contains this text (case-insensitive), e.g. @stripe.com. (max 200 chars) |
rules[].to | string | no | To/Cc contains this text (case-insensitive). (max 200 chars) |
rules[].subject | string | no | Subject contains this text (case-insensitive). (max 200 chars) |
rules[].skip_inbox | boolean | no | Archive matching received mail so it appears only in the label folder, not the Inbox. |
apply_to_existing | boolean | no | Also file already-retained mail that matches the rules. |
Returns: The created label with rules.
Example:
{
"name": "create_label",
"arguments": {
"name": "Agent/Orders",
"skip_inbox": true,
"rules": [
{
"from": "@stripe.com"
}
]
}
}
update_label
Rename, recolor, or bucket a label · Changes state · PATCH /labels/:label_id
Rename a label, change its color, or toggle bucket mode (skip_inbox). Turning bucket mode on archives received mail already in the label.
| Parameter | Type | Required | Description |
|---|---|---|---|
label_id | string | yes | Label ID (starts with lbl_) or the exact label name. (max 128 chars) |
name | string | no | New name. (max 64 chars) |
color | string | no | New hex color. |
skip_inbox | boolean | no | Bucket mode: received mail that gets this label (by rule, by replying to a conversation sent with this label, or by hand) is archived so it appears only in the label, not the Inbox. |
Returns: Updated label.
Example:
{
"name": "update_label",
"arguments": {
"label_id": "lbl_123",
"name": "Finance/Billing"
}
}
delete_label
Delete a label · Destructive · DELETE /labels/:label_id
DESTRUCTIVE: delete a label and its rules. The email itself is kept; it only loses this label.
| Parameter | Type | Required | Description |
|---|---|---|---|
label_id | string | yes | Label ID (starts with lbl_) or the exact label name. (max 128 chars) |
Returns: {ok: true}.
Example:
{
"name": "delete_label",
"arguments": {
"label_id": "lbl_123"
}
}
create_label_rule
Add an auto-filing rule · Changes state · POST /labels/:label_id/rules
Add a rule to a label so matching new mail is filed automatically. Every condition you set must match. Use inbox_id to give a receiving address its own folder; add skip_inbox to keep it out of the Inbox.
| Parameter | Type | Required | Description |
|---|---|---|---|
label_id | string | yes | Label ID (starts with lbl_) or the exact label name. (max 128 chars) |
direction | string | no | Only in (received) or out (sent) mail. Omit for both. (one of in, out) |
inbox_id | string | no | Only mail received by this inbox (inb_…). Files each receiving address into its own folder. |
from | string | no | Sender contains this text (case-insensitive), e.g. @stripe.com. (max 200 chars) |
to | string | no | To/Cc contains this text (case-insensitive). (max 200 chars) |
subject | string | no | Subject contains this text (case-insensitive). (max 200 chars) |
skip_inbox | boolean | no | Archive matching received mail so it appears only in the label folder, not the Inbox. |
apply_to_existing | boolean | no | Also file already-retained mail that matches. |
Returns: {id: lrule_…, labelId, direction, inboxId, from, to, subject, skipInbox}.
Example:
{
"name": "create_label_rule",
"arguments": {
"label_id": "Billing",
"inbox_id": "inb_123",
"skip_inbox": true
}
}
delete_label_rule
Delete an auto-filing rule · Destructive · DELETE /labels/:label_id/rules/:rule_id
DESTRUCTIVE: remove one auto-filing rule. Mail already filed keeps its label.
| Parameter | Type | Required | Description |
|---|---|---|---|
label_id | string | yes | Label ID (starts with lbl_) or the exact label name. (max 128 chars) |
rule_id | string | yes | Rule ID (starts with lrule_), from get_label. (max 128 chars) |
Returns: {ok: true}.
Example:
{
"name": "delete_label_rule",
"arguments": {
"label_id": "lbl_123",
"rule_id": "lrule_123"
}
}
label_email
Add or remove labels on an email · Changes state · POST /emails/:email_id/labels
Move a message between folders: add and/or remove labels by name or lbl_… ID. Unknown names in add are created unless create is false.
| Parameter | Type | Required | Description |
|---|---|---|---|
email_id | string | yes | Email ID (starts with em_), as returned by a list or create tool. (max 128 chars) |
add | string[] | no | Labels to add. (0–10 items) |
remove | string[] | no | Labels to remove. (0–10 items) |
create | boolean | no | Create unknown labels in add (default true). |
Returns: The updated email with labels.
Example:
{
"name": "label_email",
"arguments": {
"email_id": "em_123",
"add": [
"Billing"
],
"remove": [
"Support"
]
}
}
Drafts, attachments, and sender identities
list_sending_identities
List verified sender identities · Read-only · GET /sending-identities
Addresses and domains this workspace can send from right now (verified domains, their default From, and active inbox addresses). Call before send_email to pick a valid from.
No parameters.
Returns: {domains: [verified domain names], addresses: [sender addresses], localParts: [...]}.
Example:
{
"name": "list_sending_identities",
"arguments": {}
}
create_draft
Create a draft · Changes state · POST /drafts
Create a composer draft. Drafts hold attachments: create a draft, upload_attachment, then send_email with draft_id. Does not send anything.
| Parameter | Type | Required | Description |
|---|---|---|---|
from | string | no | Sender address on a verified domain (may be empty while drafting). |
to | string[] | no | Recipients. (0–100 items) |
cc | string[] | no | Carbon-copy recipients. (0–100 items) |
bcc | string[] | no | Blind-copy recipients. (0–100 items) |
subject | string | no | Subject line. (max 998 chars) |
html | string | no | HTML body. |
text | string | no | Plain-text body. |
reply_to_email_id | string | no | Email ID this draft replies to. |
thread_id | string | no | Thread ID this draft belongs to. |
Returns: Draft object {id: dr_…, from, to, cc, bcc, subject, html, text, attachments: []}.
Example:
{
"name": "create_draft",
"arguments": {
"from": "hello@example.com",
"to": [
"owner@example.com"
],
"subject": "Invoice"
}
}
list_drafts
List drafts · Read-only · GET /drafts
List composer drafts, most recently updated first. Paginated: the result includes pagination {offset, limit, returned, total?, has_more, next_offset}.
| Parameter | Type | Required | Description |
|---|---|---|---|
limit | integer | no | Page size. Defaults to 50. (default 50; 1–200) |
offset | integer | no | Number of records to skip. Use pagination.next_offset from the previous page. (default 0; 0–…) |
Returns: {data: [drafts], count, pagination}.
Example:
{
"name": "list_drafts",
"arguments": {}
}
get_draft
Get a draft · Read-only · GET /drafts/:draft_id
Retrieve one draft with its attachment metadata.
| Parameter | Type | Required | Description |
|---|---|---|---|
draft_id | string | yes | Draft ID (starts with dr_), as returned by a list or create tool. (max 128 chars) |
Returns: Draft object with attachments.
Example:
{
"name": "get_draft",
"arguments": {
"draft_id": "dr_123"
}
}
update_draft
Replace draft content · Changes state · PUT /drafts/:draft_id
Replace a draft's content and recipients. This is a full replacement: fields you omit are cleared, so read get_draft first and send every field you want to keep. Attachments are unaffected.
| Parameter | Type | Required | Description |
|---|---|---|---|
draft_id | string | yes | Draft ID (starts with dr_), as returned by a list or create tool. (max 128 chars) |
from | string | no | Sender address on a verified domain (may be empty while drafting). |
to | string[] | no | Recipients. (0–100 items) |
cc | string[] | no | Carbon-copy recipients. (0–100 items) |
bcc | string[] | no | Blind-copy recipients. (0–100 items) |
subject | string | no | Subject line. (max 998 chars) |
html | string | no | HTML body. |
text | string | no | Plain-text body. |
reply_to_email_id | string | no | Email ID this draft replies to. |
thread_id | string | no | Thread ID this draft belongs to. |
Returns: Updated draft object.
Example:
{
"name": "update_draft",
"arguments": {
"draft_id": "dr_123",
"from": "hello@example.com",
"to": [
"owner@example.com"
],
"subject": "Invoice (updated)",
"text": "Attached."
}
}
delete_draft
Discard a draft · Destructive · DELETE /drafts/:draft_id
DESTRUCTIVE: discard a draft and permanently delete its stored attachments.
| Parameter | Type | Required | Description |
|---|---|---|---|
draft_id | string | yes | Draft ID (starts with dr_), as returned by a list or create tool. (max 128 chars) |
Returns: {ok: true}.
Example:
{
"name": "delete_draft",
"arguments": {
"draft_id": "dr_123"
}
}
upload_attachment
Upload an attachment to a draft · Changes state · POST /drafts/:draft_id/attachments
Upload one file to a draft (max 10 files and 10 MB total per message). Provide content_base64 or a local file_path. Attachments require a paid plan at send time.
Provide at least one of: content_base64, file_path.
| Parameter | Type | Required | Description |
|---|---|---|---|
draft_id | string | yes | Draft ID (starts with dr_), as returned by a list or create tool. (max 128 chars) |
filename | string | no | File name shown to the recipient. Defaults to the basename of file_path. (max 255 chars) |
content_type | string | no | MIME type, e.g. application/pdf. Defaults to application/octet-stream. |
content_base64 | string | no | Standard base64 file content. |
file_path | string | no | Absolute path of a local file readable by the MCP server process. |
Returns: {id: att_…, filename, contentType, sizeBytes, available}.
Example:
{
"name": "upload_attachment",
"arguments": {
"draft_id": "dr_123",
"filename": "invoice.pdf",
"content_type": "application/pdf",
"file_path": "/tmp/invoice.pdf"
}
}
download_attachment
Download an attachment · Read-only · GET /attachments/:attachment_id
Download a private attachment (sent, received, or draft). Returns base64 content, or writes the file when save_to_path is set (refuses to overwrite unless overwrite is true).
| Parameter | Type | Required | Description |
|---|---|---|---|
attachment_id | string | yes | Attachment ID (starts with att_), as returned by a list or create tool. (max 128 chars) |
save_to_path | string | no | Optional absolute local path to write the file to instead of returning base64. |
overwrite | boolean | no | Allow replacing an existing file at save_to_path. Defaults to false. |
Returns: {attachment_id, filename, content_type, size_bytes, content_base64} or {attachment_id, filename, content_type, size_bytes, saved_to}.
Example:
{
"name": "download_attachment",
"arguments": {
"attachment_id": "att_123",
"save_to_path": "/tmp/invoice.pdf"
}
}
delete_attachment
Delete an attachment · Destructive · DELETE /attachments/:attachment_id
DESTRUCTIVE: permanently delete a stored attachment (for example, remove a file from a draft before sending).
| Parameter | Type | Required | Description |
|---|---|---|---|
attachment_id | string | yes | Attachment ID (starts with att_), as returned by a list or create tool. (max 128 chars) |
Returns: {ok: true}.
Example:
{
"name": "delete_attachment",
"arguments": {
"attachment_id": "att_123"
}
}
Hosted templates
list_templates
List hosted templates · Read-only · GET /templates
List hosted email templates with publish state and usage. Paginated: the result includes pagination {offset, limit, returned, total?, has_more, next_offset}.
| Parameter | Type | Required | Description |
|---|---|---|---|
lifecycle | string | no | active (default), archived, or all. (one of active, archived, all) |
query | string | no | Search by name or key. (max 120 chars) |
limit | integer | no | Page size. Defaults to 50. (default 50; 1–200) |
offset | integer | no | Number of records to skip. Use pagination.next_offset from the previous page. (default 0; 0–…) |
Returns: {data: [templates], count, pagination}.
Example:
{
"name": "list_templates",
"arguments": {
"lifecycle": "active"
}
}
create_template
Create a hosted template · Changes state · POST /templates
Create a template with an editable draft, optionally from a starter (welcome, reset, receipt, or blank). Publish it before sending by key.
| Parameter | Type | Required | Description |
|---|---|---|---|
name | string | yes | Human name. (max 120 chars) |
key | string | no | Stable send key: lowercase letters, numbers, hyphens; starts with a letter (2–64 chars). Derived from name when omitted. |
starter | string | no | Starter content. (one of blank, welcome, reset, receipt) |
Returns: {template, draft, activeVersion, versions, usage}.
Example:
{
"name": "create_template",
"arguments": {
"name": "Account welcome",
"key": "account-welcome",
"starter": "welcome"
}
}
get_template
Get a template · Read-only · GET /templates/:template_id
Retrieve a template's current draft (with revision), active published release, release history, and usage. Accepts ID or key.
| Parameter | Type | Required | Description |
|---|---|---|---|
template_id | string | yes | Template ID (tmpl_…) or key. (max 128 chars) |
Returns: {template, draft: {id, revision, subjectTemplate, htmlTemplate, textTemplate, variables, sampleData, …} | null, activeVersion, versions, usage}.
Example:
{
"name": "get_template",
"arguments": {
"template_id": "account-welcome"
}
}
update_template_draft
Save a template draft · Changes state · PUT /templates/:template_id/draft
Save the template's editable draft using optimistic concurrency: pass the current revision from get_template (409 means someone else saved first; re-read and retry). This is a full replacement of draft content: omitted fields are cleared, so send every field you want to keep. Use {{variable}} placeholders.
| Parameter | Type | Required | Description |
|---|---|---|---|
template_id | string | yes | Template ID or key. (max 128 chars) |
revision | integer | yes | Current draft revision from get_template. (1–…) |
name | string | no | Template name. (max 120 chars) |
subject_template | string | no | Subject with placeholders. (max 998 chars) |
preheader_template | string | no | Preview text. (max 240 chars) |
html_template | string | no | HTML body with placeholders. |
text_template | string | no | Plain-text body with placeholders. |
from | string | no | Default sender for sends of this template. |
reply_to | string | no | Default Reply-To. |
variables | object[] | no | Typed variable contract. Each item: {key (lowercase/underscores), label, type: text|number|url|boolean, required (default true), fallback, description}. |
variables[].key | string | yes | |
variables[].label | string | no | |
variables[].type | string | no | (one of text, number, url, boolean) |
variables[].required | boolean | no | |
variables[].fallback | any | no | |
variables[].description | string | no | |
sample_data | object | no | Sample values used for previews and tests. |
Returns: {template, draft: {revision: next}, validation: {valid, findings}}.
Example:
{
"name": "update_template_draft",
"arguments": {
"template_id": "account-welcome",
"revision": 3,
"name": "Account welcome",
"subject_template": "Welcome, {{first_name}}",
"text_template": "Hi {{first_name}}",
"variables": [
{
"key": "first_name",
"type": "text",
"required": true
}
],
"sample_data": {
"first_name": "Asha"
}
}
}
create_template_draft
Start a new draft from the published release · Changes state · POST /templates/:template_id/draft
Create a new editable draft copied from the current published release (409 if a draft already exists or nothing is published).
| Parameter | Type | Required | Description |
|---|---|---|---|
template_id | string | yes | Template ID or key. (max 128 chars) |
Returns: {draft}.
Example:
{
"name": "create_template_draft",
"arguments": {
"template_id": "account-welcome"
}
}
render_template
Render a template preview · Read-only · POST /templates/:template_id/render
Render the exact server output (subject, html, text) for the draft, the published release, or a specific version with the given data. Does not send. Returns 422 with findings when data violates the variable contract.
| Parameter | Type | Required | Description |
|---|---|---|---|
template_id | string | yes | Template ID or key. (max 128 chars) |
version_id | string | no | Optional version ID; defaults to the draft, then the published release. |
data | object | no | Variable values; defaults to the version's sample data. |
Returns: {subject, html, text, preheader, versionId, versionNumber, isDraft, findings}.
Example:
{
"name": "render_template",
"arguments": {
"template_id": "account-welcome",
"data": {
"first_name": "Asha"
}
}
}
send_template_test
Send a template test email · Sends real email · POST /templates/:template_id/test
SENDS REAL EMAIL. Send a [Test]-prefixed snapshot of the draft (or a given version) to the given recipients. Counts against usage; trial workspaces can only send to the account email or an SES simulator address.
| Parameter | Type | Required | Description |
|---|---|---|---|
template_id | string | yes | Template ID or key. (max 128 chars) |
to | string[] | yes | Test recipients. (1–100 items) |
from | string | no | Sender on a verified domain; defaults to the template's From. |
version_id | string | no | Optional version ID. |
data | object | no | Variable values; defaults to sample data. |
Returns: {id: em_…, providerMessageId, threadId, isTest: true}.
Example:
{
"name": "send_template_test",
"arguments": {
"template_id": "account-welcome",
"to": [
"owner@example.com"
]
}
}
publish_template
Publish a template release · Changes state · POST /templates/:template_id/publish
Publish the current draft as an immutable release that send_email with template.key will use. Fails with 422 findings on validation errors, or 409 if it would break the live variable contract of a template already used in production.
| Parameter | Type | Required | Description |
|---|---|---|---|
template_id | string | yes | Template ID or key. (max 128 chars) |
Returns: {template, published}.
Example:
{
"name": "publish_template",
"arguments": {
"template_id": "account-welcome"
}
}
archive_template
Archive a template · Changes state · POST /templates/:template_id/archive
Stop new sends that use this template (history is kept; reversible with restore_template). Any integration sending this key will start failing with 404.
| Parameter | Type | Required | Description |
|---|---|---|---|
template_id | string | yes | Template ID or key. (max 128 chars) |
Returns: {template}.
Example:
{
"name": "archive_template",
"arguments": {
"template_id": "account-welcome"
}
}
restore_template
Restore an archived template · Changes state · POST /templates/:template_id/restore
Make an archived template active again.
| Parameter | Type | Required | Description |
|---|---|---|---|
template_id | string | yes | Template ID or key. (max 128 chars) |
Returns: {template}.
Example:
{
"name": "restore_template",
"arguments": {
"template_id": "account-welcome"
}
}
Domains and DNS
list_domains
List domains · Read-only · GET /domains
List sending domains with aggregate setup_status (verified | checking | pending), per-record DNS state, and inbound status. Can be slow: unverified domains are re-checked live. Paginated: the result includes pagination {offset, limit, returned, total?, has_more, next_offset}.
| Parameter | Type | Required | Description |
|---|---|---|---|
limit | integer | no | Page size. Defaults to 50. (default 50; 1–200) |
offset | integer | no | Number of records to skip. Use pagination.next_offset from the previous page. (default 0; 0–…) |
Returns: {data: [domains with records], count, pagination}.
Example:
{
"name": "list_domains",
"arguments": {}
}
get_domain
Get domain setup details · Read-only · GET /domains/:domain_id
Retrieve one domain with the exact DNS records to publish (type, name, value), each record's live state from two public resolvers, dns_issues with fixes, and inbound status.
| Parameter | Type | Required | Description |
|---|---|---|---|
domain_id | string | yes | Domain ID (starts with dom_), as returned by a list or create tool. (max 128 chars) |
Returns: {id, name, status, setup_status, dns_propagating, records: [{type, name, value, verified, dns_state}], dns_issues: [{code, message, …}], inbound_domain, inbound_status}.
Example:
{
"name": "get_domain",
"arguments": {
"domain_id": "dom_123"
}
}
add_domain
Add a sending domain · Changes state · POST /domains
Register a domain you control for sending. Returns the DNS records (SES Easy DKIM CNAMEs) the owner must publish. Does not change DNS itself. Counts against the plan's domain limit.
| Parameter | Type | Required | Description |
|---|---|---|---|
name | string | yes | Bare domain name, e.g. example.com or mail.example.com. (max 253 chars) |
default_from | string | no | Optional default sender address on this domain. |
Returns: {id: dom_…, name, status: pending, records: [...], ses: {configured}}.
Example:
{
"name": "add_domain",
"arguments": {
"name": "example.com"
}
}
verify_domain
Verify a domain · Changes state · POST /domains/:domain_id/verify
Run a live SES/DNS verification check now. Safe to repeat; poll every 30–60 s after DNS changes (propagation can take minutes to hours). Sending is allowed once status is verified.
| Parameter | Type | Required | Description |
|---|---|---|---|
domain_id | string | yes | Domain ID (starts with dom_), as returned by a list or create tool. (max 128 chars) |
Returns: {domain, checks: {ses, dkim, dkim_status}, status: verified|pending}.
Example:
{
"name": "verify_domain",
"arguments": {
"domain_id": "dom_123"
}
}
delete_domain
Delete a domain · Destructive · DELETE /domains/:domain_id
DESTRUCTIVE: remove the domain from the workspace, including its inbound receiving route. Sends from it fail immediately afterwards. It does not delete DNS records at your DNS provider.
| Parameter | Type | Required | Description |
|---|---|---|---|
domain_id | string | yes | Domain ID (starts with dom_), as returned by a list or create tool. (max 128 chars) |
Returns: {ok: true}.
Example:
{
"name": "delete_domain",
"arguments": {
"domain_id": "dom_123"
}
}
get_dns_provider
Detect DNS provider and record hosts · Read-only · GET /dns/provider
Detect the domain's authoritative DNS provider and return the relative host to type into that provider for each record, the recommended DMARC record, inbound MX guidance, and whether one-click setup (Domain Connect) is available.
| Parameter | Type | Required | Description |
|---|---|---|---|
domain_id | string | yes | Domain ID (starts with dom_), as returned by a list or create tool. (max 128 chars) |
Returns: {detectionStatus, detected, zone, nameservers, recordHosts: {recordId: host}, inbound, recommendations, authentication, providers: {domainConnect: {available, providerName}}}.
Example:
{
"name": "get_dns_provider",
"arguments": {
"domain_id": "dom_123"
}
}
get_domain_connect_link
Get a one-click DNS setup link · Read-only · GET /dns/domain-connect/connect
When get_dns_provider reports providers.domainConnect.available, create a signed consent URL. Give it to the human: they open it and approve the DNS change at their provider. Nothing changes until they approve. 409 if unsupported.
| Parameter | Type | Required | Description |
|---|---|---|---|
domain_id | string | yes | Domain ID (starts with dom_), as returned by a list or create tool. (max 128 chars) |
Returns: {url, providerName}.
Example:
{
"name": "get_domain_connect_link",
"arguments": {
"domain_id": "dom_123"
}
}
Inbound email
setup_inbound
Enable inbound receiving for a domain · Changes state · POST /domains/:domain_id/inbound/setup
Provision SES inbound receiving for a verified domain. Uses the root domain when it has no conflicting MX, otherwise inbound.<domain>. Returns the MX record the owner must publish; it does not edit DNS.
| Parameter | Type | Required | Description |
|---|---|---|---|
domain_id | string | yes | Domain ID (starts with dom_), as returned by a list or create tool. (max 128 chars) |
Returns: {domain: receiving domain, status: dns_pending|ready, record: {type: MX, name, value}}.
Example:
{
"name": "setup_inbound",
"arguments": {
"domain_id": "dom_123"
}
}
verify_inbound
Verify inbound MX · Changes state · POST /domains/:domain_id/inbound/verify
Re-check the inbound MX record. Status becomes ready when both public resolvers see it.
| Parameter | Type | Required | Description |
|---|---|---|---|
domain_id | string | yes | Domain ID (starts with dom_), as returned by a list or create tool. (max 128 chars) |
Returns: {domain, status: ready|dns_pending|propagating|checking, record}.
Example:
{
"name": "verify_inbound",
"arguments": {
"domain_id": "dom_123"
}
}
list_inboxes
List inbound addresses · Read-only · GET /inboxes
List receiving addresses, optionally for one domain. Paginated: the result includes pagination {offset, limit, returned, total?, has_more, next_offset}.
| Parameter | Type | Required | Description |
|---|---|---|---|
domain_id | string | no | Optional domain ID filter. |
limit | integer | no | Page size. Defaults to 50. (default 50; 1–200) |
offset | integer | no | Number of records to skip. Use pagination.next_offset from the previous page. (default 0; 0–…) |
Returns: {data: [{id, address, name, status, domainId}], count, pagination}.
Example:
{
"name": "list_inboxes",
"arguments": {
"domain_id": "dom_123"
}
}
get_inbox
Get an inbox · Read-only · GET /inboxes/:inbox_id
Retrieve one inbound address.
| Parameter | Type | Required | Description |
|---|---|---|---|
inbox_id | string | yes | Inbox ID (starts with inb_), as returned by a list or create tool. (max 128 chars) |
Returns: Inbox object.
Example:
{
"name": "get_inbox",
"arguments": {
"inbox_id": "inb_123"
}
}
create_inbox
Create an inbound address · Changes state · POST /inboxes
Create an address such as support@<receiving domain> on a domain whose inbound status is ready (run setup_inbound and verify_inbound first). Received mail appears in list_emails with direction in.
| Parameter | Type | Required | Description |
|---|---|---|---|
domain_id | string | yes | Domain ID (starts with dom_), as returned by a list or create tool. (max 128 chars) |
local_part | string | yes | Part before @, e.g. support. (max 64 chars) |
name | string | no | Optional display name. |
Returns: {id: inb_…, address, name, status: active}.
Example:
{
"name": "create_inbox",
"arguments": {
"domain_id": "dom_123",
"local_part": "support",
"name": "Support"
}
}
update_inbox
Rename, enable, or disable an inbox · Changes state · PATCH /inboxes/:inbox_id
Rename an inbox or set its status to active / disabled.
| Parameter | Type | Required | Description |
|---|---|---|---|
inbox_id | string | yes | Inbox ID (starts with inb_), as returned by a list or create tool. (max 128 chars) |
name | string | no | New display name. |
status | string | no | New status. (one of active, disabled) |
Returns: Updated inbox.
Example:
{
"name": "update_inbox",
"arguments": {
"inbox_id": "inb_123",
"status": "disabled"
}
}
set_inbox_forwarding
Forward an inbox to another address · Sends real email · PUT /inboxes/:inbox_id/forwarding
SENDS REAL EMAIL when forwarding to someone other than the account owner: sets where an inbox's received mail is forwarded. The owner's own address activates immediately; any other address gets a confirmation email and forwarding stays pending until someone there confirms. Pass forward_to: null to turn forwarding off. Forwarded copies come from the inbox address with the original sender as Reply-To.
| Parameter | Type | Required | Description |
|---|---|---|---|
inbox_id | string | yes | Inbox ID (starts with inb_), as returned by a list or create tool. (max 128 chars) |
forward_to | string,null | yes | Forwarding target email address, or null to turn forwarding off. (max 254 chars) |
Returns: Inbox with forwardTo and forwardStatus (off, pending, or active).
Example:
{
"name": "set_inbox_forwarding",
"arguments": {
"inbox_id": "inb_123",
"forward_to": "team@example.net"
}
}
delete_inbox
Delete an inbox · Destructive · DELETE /inboxes/:inbox_id
DESTRUCTIVE: delete an inbound address. Mail already received is retained; new mail to the address is no longer filed to it.
| Parameter | Type | Required | Description |
|---|---|---|---|
inbox_id | string | yes | Inbox ID (starts with inb_), as returned by a list or create tool. (max 128 chars) |
Returns: {ok: true}.
Example:
{
"name": "delete_inbox",
"arguments": {
"inbox_id": "inb_123"
}
}
Deliverability, bounces, and suppressions
deliverability_stats
Get 30-day delivery stats · Read-only · GET /deliverability/stats
Workspace-wide 30-day totals: sent, delivery, bounce, complaint, reject, open, click, and deliveryRate (%).
No parameters.
Returns: {window: 30d, sent, delivery, bounce, complaint, reject, open, click, deliveryRate}.
Example:
{
"name": "deliverability_stats",
"arguments": {}
}
list_sender_reputation
List sender reputation · Read-only · GET /deliverability/reputation
Reputation state per exact From address: active, throttled (lower daily limit), or paused (sends return 423), with the reason and daily limit. Check this when sends fail with 423 or 429. Paginated: the result includes pagination {offset, limit, returned, total?, has_more, next_offset}.
| Parameter | Type | Required | Description |
|---|---|---|---|
limit | integer | no | Page size. Defaults to 50. (default 50; 1–200) |
offset | integer | no | Number of records to skip. Use pagination.next_offset from the previous page. (default 0; 0–…) |
Returns: {data: [{sender, status, dailyLimit, reason, cleanSince, warnedAt, pausedAt, evaluatedAt}], count, pagination}.
Example:
{
"name": "list_sender_reputation",
"arguments": {}
}
list_suppressions
List suppressions · Read-only · GET /suppressions
Workspace suppression list: recipients blocked after a permanent bounce or a spam complaint. Sends to them fail with 422. Paginated: the result includes pagination {offset, limit, returned, total?, has_more, next_offset}.
| Parameter | Type | Required | Description |
|---|---|---|---|
limit | integer | no | Page size. Defaults to 50. (default 50; 1–200) |
offset | integer | no | Number of records to skip. Use pagination.next_offset from the previous page. (default 0; 0–…) |
Returns: {data: [{email, reason, detail, created_at}], count, pagination}.
Example:
{
"name": "list_suppressions",
"arguments": {}
}
remove_suppression
Remove a bounce suppression · Destructive · DELETE /suppressions/:email
DESTRUCTIVE (weakens a safety block): remove a bounce suppression so the address can be mailed again. Only do this when the human confirms the address is now valid. Complaint suppressions cannot be removed (409).
| Parameter | Type | Required | Description |
|---|---|---|---|
email | string | yes | Suppressed recipient address. (max 320 chars) |
Returns: {ok: true}.
Example:
{
"name": "remove_suppression",
"arguments": {
"email": "fixed-mailbox@example.net"
}
}
list_blocked_recipients
List blocked recipients · Read-only · GET /blocked-recipients
Every recipient SendHQ will refuse: bounces, complaints, and domain-scoped marketing unsubscribes, with a summary by kind. Reads up to the newest 500. Paginated: the result includes pagination {offset, limit, returned, total?, has_more, next_offset}.
| Parameter | Type | Required | Description |
|---|---|---|---|
limit | integer | no | Page size. Defaults to 50. (default 50; 1–200) |
offset | integer | no | Number of records to skip. Use pagination.next_offset from the previous page. (default 0; 0–…) |
Returns: {data: [{email, domain, kind: bounce|complaint|unsubscribe, reason, detail, source, status, created_at}], count, summary: {total, bounce, complaint, unsubscribe}, pagination}.
Example:
{
"name": "list_blocked_recipients",
"arguments": {}
}
Account, usage, analytics, and keys
get_account
Get account, usage, and billing · Read-only · GET /account
Account owner email, plan/access tier, current-period recipient deliveries used vs quota, domains used vs limit, attachment transfer, reputation summary, subscription state, published plans, and workspace counts. Use it to check remaining quota or who the trial can deliver to (the account email).
No parameters.
Returns: {user: {email, …}, usage: {domainsUsed, domainLimit, recipientDeliveries, emailQuotaMonth, attachmentBytes, attachmentByteLimit, periodKey}, access: {tier, planCode}, reputation, infrastructure, billing: {status, subscriptions, …}, plans, workspace: {mailer, stats}}.
Example:
{
"name": "get_account",
"arguments": {}
}
get_analytics
Get sending analytics · Read-only · GET /analytics
Dashboard analytics for the last 7, 30, or 90 days: sent/received/delivered/bounced/blocked/opened/clicked/complaint totals, a daily timeline, top sending domains, and top subjects.
| Parameter | Type | Required | Description |
|---|---|---|---|
days | integer | no | Window in days: 7, 30 (default), or 90. (one of 7, 30, 90) |
Returns: {window, days, metrics, timeline: [{day, sent, received}], domains, topContent}.
Example:
{
"name": "get_analytics",
"arguments": {
"days": 30
}
}
list_api_keys
List API key metadata · Read-only · GET /keys
List API key names, non-secret prefixes, and last-used times. Read-only: this MCP server cannot create, rotate, or revoke keys; a human does that in the dashboard. Paginated: the result includes pagination {offset, limit, returned, total?, has_more, next_offset}.
| Parameter | Type | Required | Description |
|---|---|---|---|
limit | integer | no | Page size. Defaults to 50. (default 50; 1–200) |
offset | integer | no | Number of records to skip. Use pagination.next_offset from the previous page. (default 0; 0–…) |
Returns: {data: [{id, name, prefix, lastUsedAt, createdAt}], count, pagination}.
Example:
{
"name": "list_api_keys",
"arguments": {}
}
get_service_health
Check SendHQ service health · Read-only · GET /health
Check that the SendHQ API is up and which mail provider is active. Does not need a valid API key.
No parameters.
Returns: {ok, service, mailer}.
Example:
{
"name": "get_service_health",
"arguments": {}
}
API coverage inventory
Every operation in the public API and the tool that covers it. Everything a user can do in the dashboard that has an API is covered; the exclusions below are deliberate.
| Endpoint | Tool | Notes |
|---|---|---|
| POST /emails | send_email | Send one email |
| POST /emails/batch | send_batch | Send up to 100 individualized messages |
| GET /emails | list_emails | List sent and received email |
| GET /emails/:id | get_email | Retrieve an email and its attachments |
| PATCH /emails/:id | mark_email | Update read, archive, spam, category, or importance |
| POST /emails/:id/labels | label_email | Add or remove labels on an email |
| DELETE /emails/:id | delete_email | Delete a retained email |
| GET /emails/:id/events | list_email_events | List delivery events for an email |
| GET /threads/:id | get_thread | Retrieve a conversation chronologically |
| GET /labels | list_labels | List labels with message counts and filing rules |
| POST /labels | create_label | Create a label, optionally with auto-filing rules |
| GET /labels/:id | get_label | Retrieve a label by ID or name |
| PATCH /labels/:id | update_label | Rename, recolor, or turn a label into a bucket |
| DELETE /labels/:id | delete_label | Delete a label without deleting its email |
| POST /labels/:id/rules | create_label_rule | Add an auto-filing rule to a label |
| DELETE /labels/:id/rules/:rule_id | delete_label_rule | Delete an auto-filing rule |
| POST /drafts | create_draft | Create a composer draft |
| GET /drafts | list_drafts | List composer drafts |
| GET /drafts/:id | get_draft | Retrieve a draft and attachments |
| PUT /drafts/:id | update_draft | Replace draft content |
| DELETE /drafts/:id | delete_draft | Discard a draft |
| POST /drafts/:id/attachments | upload_attachment | Upload an attachment to a draft |
| GET /attachments/:id | download_attachment | Download a private attachment |
| DELETE /attachments/:id | delete_attachment | Delete a private attachment |
| GET /sending-identities | list_sending_identities | List verified sender identities |
| GET /templates | list_templates | List hosted templates |
| POST /templates | create_template | Create a hosted template |
| GET /templates/:id | get_template | Retrieve drafts, releases, and usage |
| PUT /templates/:id/draft | update_template_draft | Autosave a template draft |
| POST /templates/:id/draft | create_template_draft | Create a new draft from the published release |
| POST /templates/:id/render | render_template | Render exact server output |
| POST /templates/:id/test | send_template_test | Send a test snapshot |
| POST /templates/:id/publish | publish_template | Publish an immutable template release |
| POST /templates/:id/archive | archive_template | Archive a template |
| POST /templates/:id/restore | restore_template | Restore an archived template |
| POST /domains | add_domain | Add a sending domain |
| GET /domains | list_domains | List domains and cached DNS state |
| GET /domains/:id | get_domain | Retrieve domain setup details |
| POST /domains/:id/verify | verify_domain | Refresh SES and DNS verification |
| POST /domains/:id/inbound/setup | setup_inbound | Provision SES inbound receiving |
| POST /domains/:id/inbound/verify | verify_inbound | Verify inbound MX routing |
| DELETE /domains/:id | delete_domain | Delete a domain |
| GET /dns/provider | get_dns_provider | Detect the authoritative DNS provider and relative record hosts |
| GET /dns/domain-connect/connect | get_domain_connect_link | Create a Domain Connect consent link for one-click DNS setup |
| POST /inboxes | create_inbox | Create an inbound address |
| GET /inboxes | list_inboxes | List inbound addresses |
| GET /inboxes/:id | get_inbox | Retrieve an inbound address |
| PATCH /inboxes/:id | update_inbox | Rename, enable, or disable an inbox |
| PUT /inboxes/:id/forwarding | set_inbox_forwarding | Forward an inbox's received mail to another address |
| DELETE /inboxes/:id | delete_inbox | Delete an inbox while retaining messages |
| GET /deliverability/stats | deliverability_stats | Retrieve 30-day delivery statistics |
| GET /deliverability/reputation | list_sender_reputation | List reputation state by exact sender identity |
| GET /suppressions | list_suppressions | List workspace suppressions |
| DELETE /suppressions/:email | remove_suppression | Remove an eligible bounce suppression |
| GET /blocked-recipients | list_blocked_recipients | List bounces, complaints, and unsubscribes |
| GET /account | get_account | Retrieve account, usage, billing state, and workspace counts with an API key |
| GET /analytics | get_analytics | Retrieve dashboard sending analytics for 7, 30, or 90 days |
| GET /profile | get_account | Session-only twin of GET /account; the MCP server reads the API-key route. |
| POST /billing/checkout | not exposed | Billing changes are session-only by design and require the account owner in the dashboard. Billing state is readable with get_account. |
| POST /billing/cancel | not exposed | Billing changes are session-only by design and require the account owner in the dashboard. Billing state is readable with get_account. |
| POST /keys | not exposed | Deliberately excluded: an agent must not mint or destroy credentials. Keys are managed by a human in the dashboard. |
| GET /keys | list_api_keys | List API-key metadata |
| DELETE /keys/:id | not exposed | Deliberately excluded: an agent must not mint or destroy credentials. Keys are managed by a human in the dashboard. |
Deliberately not available
| Capability | Endpoints | Reason |
|---|---|---|
| Create, rotate, revoke, or delete API keys | POST /keys, DELETE /keys/:id | Deliberately excluded: an agent must not mint or destroy credentials. Keys are managed by a human in the dashboard. |
| Start a checkout or cancel a subscription | POST /billing/checkout, POST /billing/cancel | Billing changes are session-only by design and require the account owner in the dashboard. Billing state is readable with get_account. |
| Cloudflare one-click DNS (OAuth) | GET /api/dns/cloudflare/connect | Requires an interactive browser session and Cloudflare OAuth consent. Use get_domain records, get_dns_provider hosts, or get_domain_connect_link instead. |
| Sign up, log in, log out, Google account linking | /api/auth/* | Human browser authentication; the MCP server authenticates with an API key. |
| Contact support form | POST /api/contact | Public marketing-site form for humans, not a workspace operation. |
Machine-readable catalog: /docs/mcp/tools.json (schemas, annotations, endpoint mapping, exclusions). Markdown version of this page: /docs/mcp.md. With the CLI installed, sendhq commands --format json prints the same catalog.