MCP Server
Connect Claude, or any MCP client, to your Sendly account so an AI agent can work in it — with the permissions you choose, and the irreversible ones only if you tick them
Sendly runs a remote Model Context Protocol server. Point an MCP-capable AI client at it, choose what it may do, and the agent can work in your Sendly account directly — answering which domains are verified, tidying your contacts, diagnosing why a message did not arrive, building and pausing your automations, drafting the next campaign, and, if you say so, sending it.
You choose what an agent may do
Permissions that cannot be undone — sending mail, revoking an API key, taking an address off your suppression list — are never granted by default. They arrive unchecked on the consent screen, each with a plain sentence naming the consequence, and you tick them yourself. Everything else is a permission you can withdraw later without disconnecting.
Endpoint
https://app.sendly.now/api/mcpThe transport is Streamable HTTP. Authorization is OAuth 2.1 with PKCE, or a Sendly secret key.
Sendly is listed in the official MCP registry as now.sendly/sendly. The listing is
metadata only — the endpoint above, the transport it speaks, and the optional
Authorization header — because Sendly is a remote server: there is nothing to install and
no package to download. A client that finds Sendly through the registry connects to exactly
the URL you would otherwise paste in by hand.
Connect
Claude Code
claude mcp add --transport http sendly https://app.sendly.now/api/mcpThe first tool call opens a browser to Sendly, where you sign in and choose the permissions. After that the connection persists.
Setting up a different agent — Codex, Cursor, Windsurf, OpenCode, or VS Code/Copilot? See Onboard your agent for the exact command per client, or paste the agent-setup prompt into the agent itself and let it run the setup.
Claude (web and desktop)
Add Sendly as a custom connector in your Claude settings, using the endpoint URL above. Custom connectors are available on Claude's paid plans; your Claude workspace's own policy decides whether members may add them.
Any other MCP client
Sendly works with any client that speaks Streamable HTTP and supports the OAuth authorization-code flow with PKCE. Give the client the endpoint URL — there is no application to pre-register. The client discovers everything it needs and registers itself automatically:
| Discovery document | URL |
|---|---|
| Protected-resource metadata (RFC 9728) | https://app.sendly.now/.well-known/oauth-protected-resource |
| Authorization-server metadata (RFC 8414) | https://app.sendly.now/.well-known/oauth-authorization-server |
The issuer is https://app.sendly.now/api/auth. Dynamic client registration is
open, so a client that has never talked to Sendly before can still register and
start the flow unattended. Path-inserted aliases of both documents are served as
well, so clients that derive the metadata URL either way will find it.
Two ways to connect
The MCP endpoint accepts an OAuth access token or a Sendly secret key
(sk_…). OAuth suits a person connecting a desktop client: you approve
permissions on a consent screen and manage the connection in Settings →
Connected apps. A secret key suits a headless or CI agent: the key's own
permissions are what the agent may do, and you manage it in Settings → API
keys. Sending-only keys (pk_…) and dashboard sessions are still refused — a
pk_ key can only send, and a session is a browser credential nobody aimed at an
agent.
Connecting with an API key
Put the key on the Authorization header and skip the OAuth flow entirely:
claude mcp add --transport http sendly https://app.sendly.now/api/mcp \ --header "Authorization: Bearer sk_…"A key connection differs from an OAuth one in three ways worth knowing:
- The key's own scopes are the agent's permissions. Whatever you ticked when you
created the key is what the agent gets — there is no second consent screen. With one
documented exception: a handful of tools need a signed-in person rather than a
project, because the routes behind them identify a project admin from the user. Those
are never offered to a key connection whatever you ticked —
create_project,create_mailbox,delete_mailbox, and all four API-key tools. Use an OAuth connection for those. - It is bound to one project. The key's project is the target of every call, and a
projectIdargument that disagrees is refused withPROJECT_FIXEDrather than quietly ignored. To act on another project, use a key belonging to it. - It is a project credential, not a personal one. It does not appear in Settings → Connected apps, because there is no consent row behind it. You revoke it from Settings → API keys, and revocation stops the agent on its next call.
Keys created before per-capability permissions get a shorter tool list
A key created before Sendly had per-capability permissions carries only the old
coarse FULL / sending-only setting. Its scopes were inferred rather than
chosen, so on this endpoint such a key is not given any tool behind one of the nine
permissions that need explicit approval — no send_email, no send_campaign, no
update_workflow, no remove_suppression, and so on for the rest of that
list. That is not a bug and there is no box to re-tick: the
repair is to create a new key in Settings → API keys and tick the permissions you
mean. Everything else the key could always do keeps working, here and on the REST
API, unchanged.
Choosing what an agent can do
Both screens that ask you to grant capability — the OAuth consent screen and the API-key dialog — open on a named preset and then let you tick individual boxes. A preset is just a starting selection; nothing is stored except the resulting list of permissions.
| Preset | What it covers | Permissions | Contains anything irreversible? |
|---|---|---|---|
| Read only | Look at everything in this project, change nothing. | 18 | No |
| Standard access (default) | Manage contacts, templates, segments and campaign drafts, and send yourself test emails. Cannot mail anyone else. | 29 | No |
| Send & campaigns | Everything in Standard access, plus sending email and running your automations. | 32 | Yes — 3 |
| Full access | Everything, including mailboxes, new projects, and API keys. | 38 | Yes — all 9 |
Standard access is the default, and it is deliberately a capable grant: an agent that manages your contacts, segments, templates and campaign drafts, and that cannot put mail in anyone's inbox. Most people want that and nothing more.
A grant with no permissions ticked is valid. It means the client can sign you in and learn nothing else.
Connecting a read-only agent
Read only is the preset to reach for when an agent should answer questions about a project and change nothing in it. Two consequences are worth knowing before you choose it, and the server enforces both rather than merely documenting them.
- The tool list is shorter. Tools are filtered against the connection's permissions
before the agent is shown anything, so a read-only connection is never offered
send_campaign,create_contactoredit_workflowat all. It cannot call what it cannot see, and it does not spend a turn discovering a refusal. diagnose_deliveryis offered. Deliverability has a read permission of its own, which is what lets the question people ask an agent most — why did this email not arrive? — be answerable without granting a single write.
Two permissions are deliberately outside it. api-keys:read enumerates your credentials,
so it belongs in no pre-ticked preset: a read-only agent may see your contacts, but not
what your keys are allowed to do. And emails:test sits in Standard access instead,
because "read only" is a promise about the world rather than about our database — a preset
that sends mail, even to you, has broken the promise its name makes.
Permissions that need explicit approval
Nine permissions can produce an effect that nothing in the Sendly dashboard undoes. They render unchecked, with the warning below shown next to the box:
| Permission | What you are warned about |
|---|---|
emails:send | Mail sent this way reaches real inboxes and cannot be recalled. |
campaigns:send | This sends a campaign to your whole audience and cannot be recalled. |
workflows:write | An enabled workflow keeps sending on its own, long after this conversation. |
suppression:write | Removing an address lets Sendly mail someone who asked you to stop. |
projects:write | New projects count towards your plan and may be billed. |
api-keys:read | Reveals which keys exist and what each one can do. |
api-keys:write | A key created here keeps working even after you disconnect this app. |
mailboxes:write | A new mailbox starts receiving real mail on your domain, and deleting one erases every message it holds. |
mailboxes:send | Mail sent this way arrives from your own support address and cannot be recalled. |
Two of these are worth explaining, because they are the ones people query.
api-keys:read changes nothing — it is on the list for what it reveals, since a map
of which credentials exist and what each may do is a map of your account's attack
surface. And campaigns:write is not on the list: drafting a campaign and mailing
it are separate permissions now, so an agent can build a campaign for you without
being able to send it.
Code Mode (default)
A connection sees exactly two tools, not one per operation: search_tools and
execute_typescript. Every capability in Full surface below still
exists behind them, reachable the same way, under the same permissions — this changes
how many tools a client lists, not what an agent can do.
| Tool | What it does |
|---|---|
search_tools | Finds the tools this connection can reach and returns each as a declare function external_<name>(...) TypeScript signature, labelled [read-only] or [write] with its title and description. An optional query argument narrows the result to a case-insensitive substring match against a tool's name, title or description — it narrows what the connection can already reach, and never widens it. Omit it to list everything reachable. |
execute_typescript | Runs a short TypeScript program, written by the agent, in an isolated sandbox. The program calls the external_* functions search_tools declared — they are already in scope — and must return its result. await Promise.all([...]) runs independent calls in one round trip instead of several. |
Calling external_<name>(...) from inside the program reaches the identical handler a
direct call to that tool would run: the same auth, scope, project-selection,
mass-send-confirmation and audit checks, whether the agent called it by name or through
execute_typescript. Orchestrating three calls in one program costs one MCP round trip
instead of three; it does not do anything three separate tool calls could not.
search_tools declares only the tools this connection's permissions reach. Any other
external_* name is simply not defined inside the program, so calling one throws a
ReferenceError rather than a coded refusal.
A refusal inside the program — a project it may not target, a revoked connection, an
unconfirmed mass send, arguments that do not match the declaration — surfaces as a
thrown JavaScript Error, not as a separate result shape: the message reads
<CODE>: <details>, for example CONFIRMATION_REQUIRED: … or INVALID_ARGUMENTS: …,
using the same codes as Troubleshooting below. When the refusal
carries structured fields (requiredScope, upstreamCode, upstreamStatus), they
follow the details as trailing JSON, for example
TOOL_EXECUTION_FAILED: … {"upstreamCode":"CONFLICT","upstreamStatus":409}.
The program's own try/catch sees it like any other exception, and the outer tool
call is not marked as an error — the call itself succeeded; the code the agent
wrote is what failed. A program that runs for roughly 30 seconds, an infinite loop
included, is stopped and the call still answers normally, reporting that it did not
finish rather than hanging.
If a client cannot orchestrate a sandboxed program at all, Sendly can serve the
one-tool-per-operation surface below directly instead (MCP_TOOL_SURFACE=full) — an
operational switch, not something a connection asks for itself.
Full surface
The one-tool-per-operation surface execute_typescript orchestrates above, and the one
Sendly can also serve directly. An agent only sees the tools its grant covers here too:
if you approve analytics:read and nothing else, view_analytics and list_projects
are the only tools below that appear — the others are never registered, so the agent
cannot even attempt them.
Your projects
| Tool | What it does | Permission |
|---|---|---|
list_projects | List the projects this connection can act in. Use it to choose the projectId for other tools. | none |
get_project | The active project's settings: name, sending region, link-tracking mode, whether it is disabled | projects:read |
create_project | Create a new project on your account. OAuth connections only — an API key has no user to add as a member | projects:write |
Contacts and segments
| Tool | What it does | Permission |
|---|---|---|
list_contacts | List contacts, optionally filtered by email or subscription status | contacts:read |
get_contact | One contact, with its custom fields | contacts:read |
create_contact | Add a contact | contacts:write |
update_contact | Edit a contact's fields or subscription status | contacts:write |
delete_contact | Remove a contact | contacts:write |
list_segments | List segments | segments:read |
get_segment | One segment and its condition | segments:read |
list_segment_contacts | Who currently matches a segment | segments:read |
create_segment | Create a segment | segments:write |
update_segment | Edit a segment's condition | segments:write |
delete_segment | Delete a segment | segments:write |
Templates and sending domains
| Tool | What it does | Permission |
|---|---|---|
list_templates | List email templates | templates:read |
get_template | One template, with its body | templates:read |
create_template | Create a template | templates:write |
update_template | Edit a template | templates:write |
check_domain | Verification status of your sending domains | domains:read |
add_domain | Register a sending domain and return the DNS records to publish | domains:write |
verify_domain | Re-check a domain's DNS records and persist the result | domains:write |
start_domain_setup | Begin guided DNS setup and return a link you open to publish the records at your registrar | domains:write |
| Tool | What it does | Permission |
|---|---|---|
list_emails | The emails you have sent, with delivery status | emails:read |
get_email | One email and its events | emails:read |
send_test_email | Send a test message from the project's sandbox address. It can only reach the project owner's own verified account email — any other recipient is refused | emails:test |
send_email | Send a transactional email. Reaches a real inbox and cannot be recalled | emails:send |
send_test_email is how an agent proves sending works without being able to mail
anyone. It takes no from argument — the sender is the project's sandbox address, and
the route refuses a body that names one — and there is a daily cap on sandbox sends. It
is a member of Standard access, which is why that preset can confirm your setup end
to end while still being unable to put mail in a stranger's inbox.
Campaigns
| Tool | What it does | Permission |
|---|---|---|
list_campaigns | List campaigns | campaigns:read |
get_campaign | One campaign | campaigns:read |
get_campaign_stats | A campaign's delivery and engagement numbers | campaigns:read |
create_campaign | Create a campaign draft | campaigns:write |
update_campaign | Edit a draft or scheduled campaign's content or audience | campaigns:write |
manage_campaign | Cancel, pause, or resume a campaign | campaigns:write |
delete_campaign | Delete a campaign | campaigns:write |
send_campaign | Send or schedule a campaign to its full audience. Cannot be recalled once sending starts | campaigns:send |
A campaign send in a large project takes a second, deliberate call
When the project holds more than 1,000 contacts, the first send_campaign call is
refused with CONFIRMATION_REQUIRED, and the refusal tells the agent to say how many
people the campaign reaches and what it says, then call again with confirm: true only
if you agree. Nothing is sent by the refused call — the check runs before Sendly's own
API is touched.
The guard is server-side for a reason. Sendly can ask your client to confirm through the protocol, but this endpoint keeps no session, so that request reaches nobody; a guard whose only enforcement lives in the client is not a guard. Requiring a second call carrying an extra argument is something a stateless server can actually enforce.
The threshold is measured against the project's contacts rather than the campaign's audience. An audience count is a cached number a background job refreshes, so it is stale exactly when it matters — a freshly built list still reading zero. Every audience is a subset of the project's contacts, so this bound cannot be wrong in the dangerous direction. It does over-ask: a send to a three-person segment inside a large project still needs the confirmation, which is the right way to be wrong about a question whose answer cannot be recalled.
send_email is not covered and does not need to be. It takes one recipient, so mailing a
thousand people through it is a thousand visible calls rather than the single call whose
blast radius the agent never had to state.
Workflows
| Tool | What it does | Permission |
|---|---|---|
list_workflows | List automation workflows | workflows:read |
get_workflow | One workflow and its steps | workflows:read |
get_workflow_status | One workflow's current state — enabled or not, what triggers it, how many steps it has — with how its runs have gone: the total, and the count now running, waiting, completed, failed and cancelled | workflows:read |
list_workflow_executions | A workflow's runs, one row per contact | workflows:read |
create_workflow | Build a workflow from a spec: a trigger, its settings, and an ordered list of steps. It starts disabled unless you ask otherwise, so you can review it before it runs | workflows:write |
edit_workflow | Change a workflow's name, description, enabled state or trigger, and optionally replace its entire step sequence in the same call | workflows:write |
clone_workflow | Copy a workflow, steps and all, as a new draft. The copy always starts disabled; the original is not modified | workflows:write |
manage_workflow | Pause or resume a workflow. Pausing stops new runs and cancels the ones already in flight, reporting how many it ended | workflows:write |
update_workflow | Edit metadata only: name, description, trigger event, enabled state, re-entry, hourly cap. It cannot author or replace the step graph — edit_workflow is the tool that can | workflows:write |
delete_workflow | Delete a workflow and its execution history. Refused with a 409 while any of its runs are still going | workflows:write |
manage_workflow has exactly two actions, pause and resume. Reading a workflow's
state used to be a third one and is now get_workflow_status, which is a permissions fact
rather than tidying: a tool declares one permission and must be able to reach everything it
does, pausing needs workflows:write, and reading the run counts needs only
workflows:read. Left as one tool, the read action would have been refused for exactly the
connections granted write without read. Split, an agent that may look but not touch can
still answer "is this automation running, and how many contacts are inside it".
Pausing a workflow is not the same as switching it off
Setting enabled to false — through update_workflow or edit_workflow — stops the
workflow being triggered again and leaves every contact already part-way through it
walking the steps. The next delay still expires and the next email still sends.
manage_workflow with pause does both. It stops new runs and cancels the runs
already in flight, and it reports how many it cancelled, so the agent can tell "nothing
was running" apart from "I just stopped four hundred journeys". Cancelling is terminal:
resume re-opens the workflow to new runs, it does not put the cancelled contacts back
where they were.
This is the practical edge of the warning on workflows:write: an enabled workflow keeps
sending on its own, long after the conversation that enabled it.
Building a workflow's steps
create_workflow and edit_workflow take a linear list of steps, which is what almost
every automation is. The trigger step is prepended for you — do not include it — and
passing steps to edit_workflow replaces every existing step rather than merging, which
is why that tool is marked destructive.
Anything with a branch is a graph rather than a sequence, and graphs are read and written
whole at GET and PUT /api/v1/workflows/{id}/graph — workflows:read and
workflows:write respectively. A GET response is accepted verbatim by PUT on the
same path, so the round trip is: read the document, change one step, send it all back.
{
"workflow_id": "8f1c4d2e-5a7b-4a1e-9c3f-2b6d8e0a1f42",
"version": 7,
"steps": [
{
"id": "1b9f6a30-2c44-4f8e-9a01-77c2d1b5e903",
"type": "TRIGGER",
"name": "Signed up",
"position": { "x": 0, "y": 0 },
"config": { "eventName": "user.signup" },
"template_id": null
},
{
"id": "3d2e7c81-9b05-4a26-8f13-0c5a4e9d6712",
"type": "DELAY",
"name": "Wait a day",
"position": { "x": 0, "y": 160 },
"config": { "amount": 1, "unit": "days" },
"template_id": null
},
{
"id": "5a4b8e12-6d37-4c90-b2e5-1f8c3a70d954",
"type": "SEND_EMAIL",
"name": "Day 1: getting started",
"position": { "x": 0, "y": 320 },
"config": {},
"template_id": "c7e1a904-3b62-4d58-8a17-9e05f2d6b481"
}
],
"transitions": [
{
"id": "9c0d5f73-1a86-42be-9d47-6b3e8a15c027",
"from_step_id": "1b9f6a30-2c44-4f8e-9a01-77c2d1b5e903",
"to_step_id": "3d2e7c81-9b05-4a26-8f13-0c5a4e9d6712",
"condition": null,
"priority": 0
},
{
"id": "2e6a1b48-7f39-4c05-a8d2-53b90c7e6f18",
"from_step_id": "3d2e7c81-9b05-4a26-8f13-0c5a4e9d6712",
"to_step_id": "5a4b8e12-6d37-4c90-b2e5-1f8c3a70d954",
"condition": null,
"priority": 0
}
]
}Send that same document back to PUT to keep the workflow as it is, or change it first.
The rules the document follows:
- Step ids are yours. Send back an id you read to keep that step and its run history, a fresh UUID to add a step, and omit a step entirely to delete it along with its history.
- Exactly one step is the
TRIGGER— the graph's single entry node. configis typed per step type, across the nine typesTRIGGER,SEND_EMAIL,DELAY,WAIT_FOR_EVENT,CONDITION,EXIT,WEBHOOK,UPDATE_CONTACTandSEND_AT_OPTIMAL_TIME. Because the contract discriminates ontype, a generated SDK narrowsconfigfrom the type you already know. A key the contract does not name is passed through rather than deleted; a key whose value is wrong —unit: "fortnights", a webhook target that is not a URL — is rejected.- Every edge stays inside the document.
from_step_idandto_step_idmust name steps in the same payload, and a step may not point at itself.priorityorders the edges leaving one step, lowest first. ACONDITIONstep's edges carry{ "branch": "yes" }and{ "branch": "no" }, or the branch's id in the multi-branch form. - At most 200 steps and 400 transitions.
versionmoves on every structural write, and each write snapshots the graph, so a different number between two reads means somebody edited it in between.- A graph write is refused with a 409 while the workflow has runs in flight, because
those contacts are standing on the steps being replaced.
POST /api/v1/workflows/{id}/pauseclears them first.
For a linear sequence you do not need these endpoints at all: POST /api/v1/workflows and
PATCH /api/v1/workflows/{id} both accept a sequence, which is exactly what
create_workflow and edit_workflow send, so the two credentials build the same graph
rather than similar ones.
POST /api/v1/workflows/{id}/clone — the route behind clone_workflow — copies a workflow
and its whole graph server-side, from one consistent read. That is not the same as reading
a graph and writing it into a new workflow: a copy assembled from two requests can capture
an edit that landed between them and materialise a workflow that never existed.
Analytics and usage
| Tool | What it does | Permission |
|---|---|---|
view_analytics | Sent, delivered, opened and bounced counts | analytics:read |
get_usage | This month's and today's send counts against your enforced limits | usage:read |
Deliverability
| Tool | What it does | Permission |
|---|---|---|
diagnose_delivery | Why mail from one of your sending domains is not arriving: the domain's DKIM, SPF, DMARC and MX state, the project's recent bounce and complaint counters, and — if you name a recipient — whether that address is suppressed | deliverability:read |
Every signal in the answer was already readable one endpoint at a time. What no endpoint did was say what the combination means, which is the part a support conversation actually turns on: "verified, but SPF failing and 6% bouncing" is a different problem from "not verified", and telling them apart from three separate payloads was a judgement the caller had to make unaided.
So the answer leads with findings — worst first, each one a stable code, a severity of
blocking, degraded or info, what is wrong, and the fix. An agent branches on the
code, never on the wording. The codes are domain_not_registered, domain_not_verified,
recipient_suppressed, spf_failing, dmarc_missing, custom_mail_from_failed,
bounce_rate_critical, bounce_rate_elevated, complaint_rate_critical,
complaint_rate_elevated, no_recent_sends, sample_too_small_for_rates and
dns_never_checked.
Two things to read the raw signals with. The DNS statuses are the cached results of
Sendly's verification job rather than a live lookup, and identity.last_checked_at says
when they were filled. The delivery counters are the project's, over a window of 1 to
30 days that defaults to 7, because an email record does not store which domain sent it.
It has a permission of its own rather than riding on domains:read, because it reads DNS
state, delivery counters and suppression together, and a key granted "view your sending
domains" did not agree to the last of those. Nothing under it writes anything or reveals
anything a project member cannot already see in the dashboard, so it is pre-ticked from
Read only upwards.
Cleaning a list
| Tool | What it does | Permission |
|---|---|---|
validate_emails | Check up to 50 addresses in one call | validation:write |
clean_list | Start a background run over every address on a list | validation:write |
get_validation_run | How far a run has got and what it found | validation:read |
list_validation_results | One page of a run's per-address verdicts, filterable by verdict | validation:read |
These are billed per address checked, which is why the write permission is its own box
rather than part of contacts:write: an agent allowed to manage your contacts should not be
able to spend your money by looping over your list. Reading a finished run costs nothing and
sits in Read only.
Every answer carries a verdict, and it is the field to branch on. deliverable is safe to
mail. undeliverable means the domain does not exist or publishes no MX records.
risky means a throwaway-inbox provider. unknown means DNS did not answer, so that address
was not checked — it is a separate value from undeliverable on purpose, because an agent
that merged the two would remove live contacts over a network hiccup.
The other fields describe the address rather than judge it. is_personal (a free consumer
provider) and is_role_address (support@, info@) are list-quality information, not
problems: real customers use Gmail and real companies answer their support address.
is_disposable is the only flag that lowers a verdict.
clean_list cleans nothing by itself. It validates and stops — no membership is
unsubscribed and no contact is deleted. Acting on a finding is a separate call under a
different permission, which is what stops a DNS lookup that can answer unknown from
quietly shrinking an audience.
Subscriber lists
| Tool | What it does | Permission |
|---|---|---|
list_lists | The subscriber lists this project keeps, with their sizes | lists:read |
get_list | One list, with its size and its double opt-in setting | lists:read |
create_list | Create an empty list | lists:write |
update_list | Rename a list or change its settings | lists:write |
delete_list | Delete a list and every membership on it | lists:write |
A list is static membership: people who were put on it and stay until they leave. That
is what separates it from a segment, which is a live condition over contact fields, and from
a topic, which is a standing decision about a subject. member_count counts memberships in
every status — a pending invitation and an unsubscribed opt-out are both memberships — so it
is the size of the membership table, not the number of people a send would reach.
Adding people to a list is not on this surface. Subscribing is where double opt-in begins: it creates the membership as pending and mints a confirmation token whose delivery is your job. An agent putting someone on a list would be asserting a consent it has no evidence of, so the tools stop at the list itself.
delete_list deletes the consent record. The memberships go with the list, and an
unsubscribed membership is the evidence that somebody opted out — re-creating the list and
re-importing the same addresses will not find their opt-outs waiting. The mail already sent
is untouched.
Topics and consent
| Tool | What it does | Permission |
|---|---|---|
list_topics | The subjects this project mails about, and how many people answered each | topics:read |
get_contact_topic_preferences | Everything one contact has said they want | topics:read |
create_topic | Add a subject people can subscribe to | topics:write |
update_topic | Rename, re-describe, change the default, or retire a topic | topics:write |
set_topic_subscription | Record what one contact wants on one topic | topics:write |
A topic is a subject you mail about — a weekly digest, a changelog, a billing notice — and a contact's answer to one is a standing decision rather than an audience filter. It applies whatever audience a campaign selects, so choosing a different audience is not a way around it. That is the difference between a preference centre and a checkbox nobody honours.
set_topic_subscription cannot subscribe anybody. Asking it to subscribe parks the
contact at pending and hands back a confirmation link; nothing is mailed on that topic
until a person opens it, and there is no parameter to skip the step. An agent asserting that
somebody wants mail is not evidence that they do, and the reputation the mistake costs is
yours. Sendly does not send that confirmation email — you do, from your own verified domain.
Unsubscribing is the other way round and takes effect at once: withdrawing consent must
never be harder than giving it.
default_opt_in decides what SILENCE means. Left true, a contact who has never answered
counts as subscribed — which is the honest reading for a topic introduced over a list you
already have, since those people consented to hear from you. Set false, absence means "not
asked" and only an explicit yes counts. subscribed_count reports explicit answers only, so
it reads low on a default_opt_in topic; that is how many people answered, not how many
would receive the mail.
There is no delete. archived: true retires a topic — it leaves the preference centre and
stops being mailable — and every opt-out recorded against it survives, because deleting the
topic would delete the choices people made about it.
Events and webhooks
| Tool | What it does | Permission |
|---|---|---|
list_events | The custom events your application has recorded | events:read |
record_event | Record a custom event against an existing contact | events:write |
list_webhooks | List webhook endpoints | webhooks:read |
create_webhook | Add a webhook endpoint | webhooks:write |
update_webhook | Edit a webhook endpoint | webhooks:write |
delete_webhook | Delete a webhook endpoint | webhooks:write |
Suppression list
| Tool | What it does | Permission |
|---|---|---|
list_suppressions | The addresses Sendly refuses to mail | suppression:read |
add_suppression | Block an address | suppression:write |
remove_suppression | Un-block an address, re-enabling mail to it | suppression:write |
Mailboxes
Mailboxes are the conversational side: a real inbox behind an address like
support@yourdomain.com, so mail sent there arrives in Sendly — and, with its own
permission, an agent can write and send a new message from that address.
| Tool | What it does | Permission |
|---|---|---|
list_mailboxes | The mailboxes on this project's domains, with each one's status and domain | mailboxes:read |
get_mailbox | One mailbox, plus the IMAP and SMTP host, port and username for connecting a mail client. The password is not included and cannot be read back | mailboxes:read |
create_mailbox | Create a mailbox on a verified domain | mailboxes:write |
delete_mailbox | Permanently delete a mailbox and every message it holds | mailboxes:write |
compose_mailbox_email | Write an email for a mailbox: draft one from a short brief, rewrite a draft you already have, or suggest subject lines. Returns text and sends nothing — the result always says sent: false | mailboxes:read |
send_mailbox_email | Send a new plain-text email from a mailbox's own address, to up to twenty recipients. The recipient can reply, it threads into that mailbox's conversations, and it cannot be recalled. Suppressed recipients and mail the content scanner rejects are refused; a mailbox may send 60 messages an hour this way | mailboxes:send |
Five facts about these that are easier to know now than to discover later:
- No agent tool reads a mailbox's messages. The mail a mailbox receives is
third-party correspondence, and no permission in the vocabulary covers reading it.
get_mailboxreturns the connection settings, never the contents. - A mailbox cannot be changed after it is created. There is no update route and no update tool — deliberately. To change an address, delete the mailbox and create the one you want.
- Quotas are not supported. Mailboxes are created with no storage limit and there is no way to add one afterwards. The tool does not offer the argument, and the route refuses it rather than accepting a value it would never apply.
- The domain must be verified first, and a project may hold at most ten
mailboxes. Both are refused with a
409, not silently worked around. - Drafting and sending are different permissions.
compose_mailbox_emailonly needsmailboxes:readbecause it changes nothing: it hands the agent text to show you. Putting that text in someone's inbox as your support address issend_mailbox_emailundermailboxes:send, which is separate fromemails:sendon purpose — an agent trusted to send a receipt from your verified domain has not thereby been trusted to open a conversation as your support desk. It arrives unchecked, and a well-behaved agent shows you the draft and confirms the recipients before calling it.
Creating or deleting a mailbox needs an OAuth connection, not a key
create_mailbox and delete_mailbox require a signed-in connection whose user is an
admin of the project. An API key carries no user at all, so those two tools are
never offered to a key connection — they are absent from its tool list rather than
failing when it tries. list_mailboxes and get_mailbox work normally with a key.
Deletion is the sharpest tool on this surface: every message the mailbox holds is
erased, Sendly keeps no other copy, and neither the dashboard nor support can bring
it back. Mail sent to the address afterwards is rejected. That is why mailboxes:write
arrives unchecked.
API keys
| Tool | What it does | Permission |
|---|---|---|
list_api_keys | Which keys exist, what each may do, when each was last used. No secret is returned — only the last four characters still exist anywhere | api-keys:read |
create_api_key | Create a new key. The secret is not returned to the agent — the result carries a one-time link only you can open | api-keys:write |
rotate_api_key | Replace a key's secret in place, keeping its name and permissions. Same one-time link; the old secret stops working immediately | api-keys:write |
revoke_api_key | Permanently revoke a key. Anything still using it fails on its next request, and it cannot be restored | api-keys:write |
An agent can create and rotate keys, and it still never sees a secret — see Tools that hand you a link below for how that works. Two limits are worth stating plainly:
-
A new key cannot be broader than the connection that made it.
create_api_keyrequires an explicit list of permissions, and any permission the connection does not itself hold is refused withSCOPE_ESCALATION. The request is rejected outright, never quietly trimmed to fit — so an agent cannot useapi-keys:writeto manufacture a capability you never granted it. Only your own dashboard session is exempt, because a project admin already has full authority over their own project. -
Every tool in this section needs an OAuth connection. All four API-key routes identify a project admin from the signed-in user, and an API key carries no user, so a key connection could never call one. They are therefore not offered to a key connection at all — absent from its tool list rather than failing when it tries. This is a designed property, not a failure mode: a tool an agent cannot use should not be in its list. An agent connected with a key cannot list, create, rotate or revoke keys.
list_api_keysis the one that looks out of place, so it is worth saying why it is here. This rule is about the shape of the route, not about danger:api-keys:readis not one of the permissions needing explicit approval, and merely reading is not irreversible, yet the route still requires a signed-in user and so the tool still cannot be used by a key. Every other tool withheld in this guide is withheld because of what it can do; this one is withheld because of who it needs to be.
A key an agent creates outlives the agent
This is the one capability that escapes its own revocation. A key minted here is a
separate credential: it consults no consent row, it keeps working after you disconnect
the app that asked for it, and you revoke it from Settings → API keys rather than
from Settings → Connected apps. That is the whole reason api-keys:write is
unchecked by default and carries a warning.
Tools that hand you a link
Three tools cannot finish inside the agent, and answer with
status: "action_required" and a URL for you to open instead of a result:
| Tool | What the link does | How long it lasts |
|---|---|---|
create_api_key | Shows the new key's secret, once | 5 minutes |
rotate_api_key | Shows the rotated key's new secret, once | 5 minutes |
start_domain_setup | Guided DNS setup at your registrar, then returns you to Sendly | Short-lived, set by the session |
For the two key tools the reason is that a secret must never enter a tool result. Anything an agent receives is written into the model's context, the client's transcript, and every log that transcript passes through — a permanent credential in all three. So the secret does not cross the boundary at all. Instead the link is a single-use, five-minute ticket, and opening it requires a signed-in Sendly session belonging to an admin of the project — a credential no agent has, and one an OAuth token or an API key cannot produce. The agent that asked for the key cannot read it, including the one that just broke your deployment by rotating it. Holding the URL is not enough on its own, and a link opened by the wrong person is spent rather than honoured, so if that happens, rotate again.
start_domain_setup uses the same shape for a different reason: publishing DKIM, SPF and
MX records happens at your registrar, where Sendly has no credentials and never will.
What the agent should do with the link
Relay it to you and say it opens once. A well-behaved client may also offer to open the window for you — that is an optional extra, not the mechanism. The tool is complete either way, because the URL is in the result the model can read out. If your client never offers to open anything, nothing is broken and nothing has been skipped.
Permissions and consent
During the OAuth flow, Sendly shows you a consent screen listing exactly what the client is asking for, with a checkbox each. These are the descriptions you will read:
| Permission | Consent-screen wording | Needs explicit approval |
|---|---|---|
emails:send | Send emails from your verified domains | Yes |
emails:read | View the emails you have sent and their delivery status | No |
contacts:read | View your contacts and their custom fields | No |
contacts:write | Create, update, and delete your contacts | No |
campaigns:read | View your campaigns and their performance | No |
campaigns:write | Create, edit, and organize your campaigns | No |
segments:read | View your segments and who belongs to them | No |
segments:write | Create, edit, and delete your segments | No |
workflows:read | View your automation workflows and their runs | No |
workflows:write | Create, edit, enable, and delete your automation workflows | Yes |
templates:read | View your email templates | No |
templates:write | Create, edit, and delete your email templates | No |
domains:read | View your sending domains and their verification status | No |
domains:write | Add and remove sending domains, and trigger verification | No |
webhooks:read | View your webhook endpoints and their delivery history | No |
webhooks:write | Create, edit, and delete your webhook endpoints | No |
suppression:read | View the addresses on your suppression list | No |
suppression:write | Add and remove addresses on your suppression list | Yes |
analytics:read | View your sending analytics and engagement metrics | No |
usage:read | View your usage totals and billing limits | No |
events:read | View the custom events your application has recorded | No |
events:write | Record custom events for your contacts | No |
projects:read | View your projects and their settings | No |
projects:write | Create new projects on your account | Yes |
api-keys:read | See which API keys exist, including what each one is allowed to do | Yes |
api-keys:write | Create, rotate, and revoke API keys — these keep working even after you disconnect this app | Yes |
campaigns:send | Send or schedule your campaigns to their audience | Yes |
mailboxes:read | View the mailboxes on your domains and their settings | No |
mailboxes:write | Create and delete mailboxes on your verified domains | Yes |
emails:test | Send test emails to your own address from the Sendly sandbox | No |
deliverability:read | Check why mail from one of your domains is not arriving | No |
mailboxes:send | Write and send new email from your hosted mailboxes, as that address | Yes |
validation:read | View your email validation runs and their results | No |
validation:write | Check whether email addresses can receive mail — this is billed per address | No |
topics:read | View the topics you mail about and who is subscribed to each | No |
topics:write | Create and edit topics, and change what your contacts are subscribed to — this decides who your campaigns reach | No |
lists:read | View your subscriber lists and who is on them | No |
lists:write | Create, rename, and delete your subscriber lists | No |
Nothing happens on your account until you approve, and no tool ever runs under a permission you did not grant.
Every permission in this table has at least one endpoint behind it, so nothing you grant
here is inert. Two of them have no AGENT TOOL yet: lists:read and lists:write govern
the /api/v1/lists endpoints, which an API key or an OAuth token can call directly, and
the MCP surface does not offer a list tool. Granting them to an agent connection today
therefore widens what a token could do over HTTP and changes nothing the agent itself can
reach — which is worth knowing before you tick them.
Choosing a project
Every tool except list_projects operates on one project.
- An OAuth connection covers every project you belong to, and each tool takes an
optional
projectId. Belong to exactly one? Leave it out; Sendly infers it. Belong to several? The agent must pass one, or the call is refused withPROJECT_REQUIREDand told to calllist_projectsfirst. Well-behaved agents do this on their own. AprojectIdthat is not one of your projects gets the samePROJECT_REQUIREDbefore the tool does anything, worded identically whether that project exists or not. - An API-key connection is bound to the key's own project. The argument is not
offered at all, and a client that sends one anyway is refused with
PROJECT_FIXED.
Sendly refuses rather than guessing in both cases on purpose: silently picking "the
first" project, or silently ignoring a projectId the agent believed it was acting
on, would mean acting on the wrong tenant's data.
Changing your mind
To withdraw one permission, go to Settings → Connected apps and untick it. The connection stays; the agent keeps everything else.
To end the connection entirely, choose Disconnect on the same page. For a key connection, revoke the key in Settings → API keys instead.
Revocation takes effect on the agent's next request
Every tool call re-checks your live grant before it runs, so a withdrawn permission starts being refused at once — even though the agent's access token has not expired — and disconnecting stops the agent entirely on its very next call.
The tool list catches up shortly after. An agent's listing is built from the token it
is holding, so a withdrawn tool can still appear there until that token is replaced:
every call of it is refused with SCOPE_MISSING in the meantime, so no authority
survives the change. The token mint reads the same live grant the gate does, which
means the withdrawn tool disappears from the listing as soon as the agent refreshes —
within an hour, since access tokens last that long — and immediately if it reconnects.
A refresh presented after a full disconnect is refused outright rather than honoured.
Troubleshooting
Failures come back to the agent as tool results carrying a code, not as transport errors, so the agent can explain them to you rather than just dropping the connection.
| What you see | What it means | Fix |
|---|---|---|
401 from the endpoint | No valid credential was presented | Run the OAuth flow, or put a valid sk_… key on the Authorization header. A pk_… key or a browser session will not be accepted |
SCOPE_MISSING | This connection was never granted that permission, or it was withdrawn — or the key predates per-capability permissions and the tool is an irreversible one | Re-approve it in Settings → Connected apps, or create a new key in Settings → API keys with the permissions ticked |
CONSENT_REVOKED | The connection was disconnected from your Sendly account | Reconnect the app from Settings → Connected apps |
KEY_REVOKED | The API key this connection uses was revoked or rotated | Reconnect with a current key from Settings → API keys |
PROJECT_REQUIRED | Your account has more than one project (or none), so the target is ambiguous — or the projectId passed is not one of your projects | Have the agent call list_projects and pass the chosen id as projectId |
PROJECT_FIXED | An API-key connection was given a projectId, but a key is bound to one project | Drop the argument, or use a key belonging to the other project |
USER_DECLINED | Your client asked you to confirm an irreversible action and you said no. Nothing was done — the refusal happens before Sendly is called at all | Nothing to fix. Tell the agent what you would like instead |
CONFIRMATION_REQUIRED | send_campaign was called in a project with more than 1,000 contacts without confirm: true. Nothing was sent | Have the agent tell you who the campaign reaches and what it says, then call again with confirm: true |
INVALID_ARGUMENTS | The arguments cannot produce a call — a tool's own cross-field rule refused them, for example send_email given a fromName with no from. Nothing was done | The message says what to change. Do not retry the same arguments: they produce the same refusal |
SCOPE_ESCALATION | create_api_key asked for a permission this connection does not itself hold | Ask for a key whose permissions are a subset of what you granted the agent, or create the key yourself in Settings → API keys |
TOOL_EXECUTION_FAILED | The call reached Sendly but could not be completed | Retry. If it persists, contact support@sendly.now |
Where the confirmation for an irreversible action actually comes from
Your MCP client's own approval prompt is what asks you before an irreversible tool runs, and it asks because of its own policy — most clients confirm every tool call, or every call you have not already approved for the session. Sendly does not trigger it.
The destructive annotation Sendly publishes marks a narrower thing, and it is worth
knowing which: a tool that erases or overwrites something that was already there —
delete_contact, edit_workflow (whose steps replace every existing step),
revoke_api_key (which invalidates a live secret), delete_list (which takes the
memberships with it). A send is not marked destructive, because it destroys
nothing; it is irreversible in the other direction, and no annotation captures that.
Do not read an unmarked tool as a safe one.
Sendly can additionally ask through the protocol, and USER_DECLINED is the
answer a decline produces, but only clients on a transport that carries a
session-scoped conversation will ever show it; on today's endpoint that request cannot
be delivered, so no prompt of Sendly's own appears. Nothing depends on it: the
permission you ticked on the consent screen is the grant, and every interactive tool
is complete by returning a link.
The one confirmation Sendly enforces itself is the mass-send
guard: above 1,000 contacts, send_campaign requires a second call
carrying confirm: true. That one works on this transport precisely because it asks for
an argument rather than for a conversation.
How it relates to the rest of the platform
The MCP server is not a separate backend. Every tool call goes out over Sendly's
own public REST API carrying the same credential you connected with, so the same
scope checks, membership rules, and project-disabled rules apply to an agent as to
curl or the SDKs. There is no privileged shortcut.
Where to read the routes a tool calls
The API reference is generated from Sendly's OpenAPI contract,
so every route named on this page has its own page there, with the full request and
response schemas. The sendly-js and sendly-python SDKs are generated from that same
contract on their own release cadence, so a route added since their last release reaches
them at the next one. Until then it is reachable the way everything here is: over HTTP
with your key, or through the MCP tool that wraps it.