Sendly MCP

Send transactional email, run campaigns, and manage contacts, lists and segments in Sendly. Remote server with OAuth sign-in.

Hosted MCP Server

npx add-mcp 'https://app.sendly.now/api/mcp'

Installs into Claude Code, Codex, Cursor and more

Documentation

MCP Server (/guides/mcp)

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.

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 [#endpoint]

https://app.sendly.now/api/mcp

The 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 [#connect]

Claude Code [#claude-code]

The 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) [#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 [#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 documentURL
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.

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 [#connecting-with-an-api-key]

Put the key on the Authorization header and skip the OAuth flow entirely:

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 projectId argument that disagrees is refused with PROJECT_FIXED rather 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.
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 [#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.

PresetWhat it coversPermissionsContains anything irreversible?
Read onlyLook at everything in this project, change nothing.18No
Standard access* *(default)Manage contacts, templates, segments and campaign drafts, and send yourself test emails. Cannot mail anyone else.29No
Send & campaignsEverything in Standard access, plus sending email and running your automations.32Yes — 3
Full accessEverything, including mailboxes, new projects, and API keys.38Yes — 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 [#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_contact or edit_workflow at all. It cannot call what it cannot see, and it does not spend a turn discovering a refusal.
  • diagnose_delivery is 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 [#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:

PermissionWhat you are warned about
emails:sendMail sent this way reaches real inboxes and cannot be recalled.
campaigns:sendThis sends a campaign to your whole audience and cannot be recalled.
workflows:writeAn enabled workflow keeps sending on its own, long after this conversation.
suppression:writeRemoving an address lets Sendly mail someone who asked you to stop.
projects:writeNew projects count towards your plan and may be billed.
api-keys:readReveals which keys exist and what each one can do.
api-keys:writeA key created here keeps working even after you disconnect this app.
mailboxes:writeA new mailbox starts receiving real mail on your domain, and deleting one erases every message it holds.
mailboxes:sendMail 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) [#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.

ToolWhat it does
search_toolsFinds 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_typescriptRuns 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 [#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 [#your-projects]

ToolWhat it doesPermission
list_projectsList the projects this connection can act in. Use it to choose the projectId for other tools.none
get_projectThe active project's settings: name, sending region, link-tracking mode, whether it is disabledprojects:read
create_projectCreate a new project on your account. OAuth connections only — an API key has no user to add as a memberprojects:write

Contacts and segments [#contacts-and-segments]

ToolWhat it doesPermission
list_contactsList contacts, optionally filtered by email or subscription statuscontacts:read
get_contactOne contact, with its custom fieldscontacts:read
create_contactAdd a contactcontacts:write
update_contactEdit a contact's fields or subscription statuscontacts:write
delete_contactRemove a contactcontacts:write
list_segmentsList segmentssegments:read
get_segmentOne segment and its conditionsegments:read
list_segment_contactsWho currently matches a segmentsegments:read
create_segmentCreate a segmentsegments:write
update_segmentEdit a segment's conditionsegments:write
delete_segmentDelete a segmentsegments:write

Templates and sending domains [#templates-and-sending-domains]

ToolWhat it doesPermission
list_templatesList email templatestemplates:read
get_templateOne template, with its bodytemplates:read
create_templateCreate a templatetemplates:write
update_templateEdit a templatetemplates:write
check_domainVerification status of your sending domainsdomains:read
add_domainRegister a sending domain and return the DNS records to publishdomains:write
verify_domainRe-check a domain's DNS records and persist the resultdomains:write
start_domain_setupBegin guided DNS setup and return a link you open to publish the records at your registrardomains:write

Email [#email]

ToolWhat it doesPermission
list_emailsThe emails you have sent, with delivery statusemails:read
get_emailOne email and its eventsemails:read
send_test_emailSend 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 refusedemails:test
send_emailSend a transactional email. Reaches a real inbox and cannot be recalledemails: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 [#campaigns]

ToolWhat it doesPermission
list_campaignsList campaignscampaigns:read
get_campaignOne campaigncampaigns:read
get_campaign_statsA campaign's delivery and engagement numberscampaigns:read
create_campaignCreate a campaign draftcampaigns:write
update_campaignEdit a draft or scheduled campaign's content or audiencecampaigns:write
manage_campaignCancel, pause, or resume a campaigncampaigns:write
delete_campaignDelete a campaigncampaigns:write
send_campaignSend or schedule a campaign to its full audience. Cannot be recalled once sending startscampaigns:send
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 [#workflows]

ToolWhat it doesPermission
list_workflowsList automation workflowsworkflows:read
get_workflowOne workflow and its stepsworkflows:read
get_workflow_statusOne 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 cancelledworkflows:read
list_workflow_executionsA workflow's runs, one row per contactworkflows:read
create_workflowBuild 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 runsworkflows:write
edit_workflowChange a workflow's name, description, enabled state or trigger, and optionally replace its entire step sequence in the same callworkflows:write
clone_workflowCopy a workflow, steps and all, as a new draft. The copy always starts disabled; the original is not modifiedworkflows:write
manage_workflowPause or resume a workflow. Pausing stops new runs and cancels the ones already in flight, reporting how many it endedworkflows:write
update_workflowEdit 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 canworkflows:write
delete_workflowDelete a workflow and its execution history. Refused with a 409 while any of its runs are still goingworkflows: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".

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 [#building-a-workflows-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.
  • config is typed per step type, across the nine types TRIGGER, SEND_EMAIL, DELAY, WAIT_FOR_EVENT, CONDITION, EXIT, WEBHOOK, UPDATE_CONTACT and SEND_AT_OPTIMAL_TIME. Because the contract discriminates on type, a generated SDK narrows config from 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_id and to_step_id must name steps in the same payload, and a step may not point at itself. priority orders the edges leaving one step, lowest first. A CONDITION step'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.
  • version moves 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}/pause clears 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 [#analytics-and-usage]

ToolWhat it doesPermission
view_analyticsSent, delivered, opened and bounced countsanalytics:read
get_usageThis month's and today's send counts against your enforced limitsusage:read

Deliverability [#deliverability]

ToolWhat it doesPermission
diagnose_deliveryWhy 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 suppresseddeliverability: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 [#cleaning-a-list]

ToolWhat it doesPermission
validate_emailsCheck up to 50 addresses in one callvalidation:write
clean_listStart a background run over every address on a listvalidation:write
get_validation_runHow far a run has got and what it foundvalidation:read
list_validation_resultsOne page of a run's per-address verdicts, filterable by verdictvalidation: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 [#subscriber-lists]

ToolWhat it doesPermission
list_listsThe subscriber lists this project keeps, with their sizeslists:read
get_listOne list, with its size and its double opt-in settinglists:read
create_listCreate an empty listlists:write
update_listRename a list or change its settingslists:write
delete_listDelete a list and every membership on itlists: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 [#topics-and-consent]

ToolWhat it doesPermission
list_topicsThe subjects this project mails about, and how many people answered eachtopics:read
get_contact_topic_preferencesEverything one contact has said they wanttopics:read
create_topicAdd a subject people can subscribe totopics:write
update_topicRename, re-describe, change the default, or retire a topictopics:write
set_topic_subscriptionRecord what one contact wants on one topictopics: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 [#events-and-webhooks]

ToolWhat it doesPermission
list_eventsThe custom events your application has recordedevents:read
record_eventRecord a custom event against an existing contactevents:write
list_webhooksList webhook endpointswebhooks:read
create_webhookAdd a webhook endpointwebhooks:write
update_webhookEdit a webhook endpointwebhooks:write
delete_webhookDelete a webhook endpointwebhooks:write

Suppression list [#suppression-list]

ToolWhat it doesPermission
list_suppressionsThe addresses Sendly refuses to mailsuppression:read
add_suppressionBlock an addresssuppression:write
remove_suppressionUn-block an address, re-enabling mail to itsuppression:write

Mailboxes [#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.

ToolWhat it doesPermission
list_mailboxesThe mailboxes on this project's domains, with each one's status and domainmailboxes:read
get_mailboxOne mailbox, plus the IMAP and SMTP host, port and username for connecting a mail client. The password is not included and cannot be read backmailboxes:read
create_mailboxCreate a mailbox on a verified domainmailboxes:write
delete_mailboxPermanently delete a mailbox and every message it holdsmailboxes:write
compose_mailbox_emailWrite 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: falsemailboxes:read
send_mailbox_emailSend 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 waymailboxes: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_mailbox returns 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_email only needs mailboxes:read because it changes nothing: it hands the agent text to show you. Putting that text in someone's inbox as your support address is send_mailbox_email under mailboxes:send, which is separate from emails:send on 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.
`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 [#api-keys]

ToolWhat it doesPermission
list_api_keysWhich keys exist, what each may do, when each was last used. No secret is returned — only the last four characters still exist anywhereapi-keys:read
create_api_keyCreate a new key. The secret is not returned to the agent — the result carries a one-time link only you can openapi-keys:write
rotate_api_keyReplace a key's secret in place, keeping its name and permissions. Same one-time link; the old secret stops working immediatelyapi-keys:write
revoke_api_keyPermanently revoke a key. Anything still using it fails on its next request, and it cannot be restoredapi-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_key requires an explicit list of permissions, and any permission the connection does not itself hold is refused with SCOPE_ESCALATION. The request is rejected outright, never quietly trimmed to fit — so an agent cannot use api-keys:write to 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_keys is 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:read is 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.

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 [#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:

ToolWhat the link doesHow long it lasts
create_api_keyShows the new key's secret, once5 minutes
rotate_api_keyShows the rotated key's new secret, once5 minutes
start_domain_setupGuided DNS setup at your registrar, then returns you to SendlyShort-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.

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 [#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:

PermissionConsent-screen wordingNeeds explicit approval
emails:sendSend emails from your verified domainsYes
emails:readView the emails you have sent and their delivery statusNo
contacts:readView your contacts and their custom fieldsNo
contacts:writeCreate, update, and delete your contactsNo
campaigns:readView your campaigns and their performanceNo
campaigns:writeCreate, edit, and organize your campaignsNo
segments:readView your segments and who belongs to themNo
segments:writeCreate, edit, and delete your segmentsNo
workflows:readView your automation workflows and their runsNo
workflows:writeCreate, edit, enable, and delete your automation workflowsYes
templates:readView your email templatesNo
templates:writeCreate, edit, and delete your email templatesNo
domains:readView your sending domains and their verification statusNo
domains:writeAdd and remove sending domains, and trigger verificationNo
webhooks:readView your webhook endpoints and their delivery historyNo
webhooks:writeCreate, edit, and delete your webhook endpointsNo
suppression:readView the addresses on your suppression listNo
suppression:writeAdd and remove addresses on your suppression listYes
analytics:readView your sending analytics and engagement metricsNo
usage:readView your usage totals and billing limitsNo
events:readView the custom events your application has recordedNo
events:writeRecord custom events for your contactsNo
projects:readView your projects and their settingsNo
projects:writeCreate new projects on your accountYes
api-keys:readSee which API keys exist, including what each one is allowed to doYes
api-keys:writeCreate, rotate, and revoke API keys — these keep working even after you disconnect this appYes
campaigns:sendSend or schedule your campaigns to their audienceYes
mailboxes:readView the mailboxes on your domains and their settingsNo
mailboxes:writeCreate and delete mailboxes on your verified domainsYes
emails:testSend test emails to your own address from the Sendly sandboxNo
deliverability:readCheck why mail from one of your domains is not arrivingNo
mailboxes:sendWrite and send new email from your hosted mailboxes, as that addressYes
validation:readView your email validation runs and their resultsNo
validation:writeCheck whether email addresses can receive mail — this is billed per addressNo
topics:readView the topics you mail about and who is subscribed to eachNo
topics:writeCreate and edit topics, and change what your contacts are subscribed to — this decides who your campaigns reachNo
lists:readView your subscriber lists and who is on themNo
lists:writeCreate, rename, and delete your subscriber listsNo

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 [#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 with PROJECT_REQUIRED and told to call list_projects first. Well-behaved agents do this on their own. A projectId that is not one of your projects gets the same PROJECT_REQUIRED before 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 [#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.

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 [#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 seeWhat it meansFix
401 from the endpointNo valid credential was presentedRun 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_MISSINGThis connection was never granted that permission, or it was withdrawn — or the key predates per-capability permissions and the tool is an irreversible oneRe-approve it in Settings → Connected apps, or create a new key in Settings → API keys with the permissions ticked
CONSENT_REVOKEDThe connection was disconnected from your Sendly accountReconnect the app from Settings → Connected apps
KEY_REVOKEDThe API key this connection uses was revoked or rotatedReconnect with a current key from Settings → API keys
PROJECT_REQUIREDYour account has more than one project (or none), so the target is ambiguous — or the projectId passed is not one of your projectsHave the agent call list_projects and pass the chosen id as projectId
PROJECT_FIXEDAn API-key connection was given a projectId, but a key is bound to one projectDrop the argument, or use a key belonging to the other project
USER_DECLINEDYour client asked you to confirm an irreversible action and you said no. Nothing was done — the refusal happens before Sendly is called at allNothing to fix. Tell the agent what you would like instead
CONFIRMATION_REQUIREDsend_campaign was called in a project with more than 1,000 contacts without confirm: true. Nothing was sentHave the agent tell you who the campaign reaches and what it says, then call again with confirm: true
INVALID_ARGUMENTSThe 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 doneThe message says what to change. Do not retry the same arguments: they produce the same refusal
SCOPE_ESCALATIONcreate_api_key asked for a permission this connection does not itself holdAsk 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_FAILEDThe call reached Sendly but could not be completedRetry. If it persists, contact support@sendly.now
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 [#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.

The [API reference](/api-reference/overview) 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. Per-client setup commands, and a prompt an agent can fetch and run itself Server-to-server credentials — for your own code, and for connecting a headless agent The REST surface every MCP tool call goes through