Skip to main content

Developer docs

Connect Claude, send events, track your website

Set up Claude to work in your Envoi account, then find the developer details: the events API, website tracking, forms, and webhooks. Every example uses your real Envoi address.

Let Claude build and send your emails for you

Connect Claude to your Envoi account and ask for what you want in plain English: “make a welcome email in our brand colours”, “add these 40 people to the VIP list”, or “send the autumn sale to everyone on the newsletter list on Tuesday at 9am”. Claude does the work inside your account, and you can see everything it made in Envoi.

Sending is guarded by Envoi itself, not by the conversation. Claude can only send with a key that has the send scope, and then only with an approval: Claude asks with approval_request, a teammate approves on the Approvals page, and the approval covers that exact content, audience and send time, once. Edit the campaign afterwards and the approval no longer applies. A workspace owner can instead let agents send on their own in Settings, with a daily cap per agent; then a send takes two calls, and the second must confirm the exact recipient count the first returned. Envoi cannot see your conversation either way. Campaigns can only use your verified senders, or Envoi's shared sending address.

Design emails

Start an email from scratch, a preset or an existing template, edit it block by block, find images, and preview the real send with every problem listed. Every edit is saved in the email’s version history.

Manage contacts, lists and segments

Find people, count an audience exactly as a send would, save segments from conditions, make lists, add or update up to 500 contacts at a time, import a CSV file, and define custom fields. Anyone who unsubscribed stays unsubscribed, addresses on your suppression list are never added, and SMS consent is only ever recorded by your team. Contact details stay masked unless the key has the Read contact details scope.

Set up, check and send campaigns

Claude sets up email campaigns as drafts and dry-runs them: recipients, suppressions, content problems and plan limits. To send, a teammate approves that exact version on the Approvals page, once. Change the campaign after that and Claude has to ask again.

Build, check and turn on flows

Claude builds automated flows step by step: triggers, emails, texts, waits and branches. It walks a contact or a sample through one to show what they would get, when, and why, without sending anything. To turn a flow on, a teammate approves that exact version on the Approvals page. Change it after that and Claude has to ask again.

Build and publish signup forms

Claude builds signup forms: the fields, the design, when and where they appear, the list people join, and email and SMS consent. Consent boxes are always optional and never ticked in advance. Putting a form live needs a key with the publish scope, and then Claude hands you the embed code.

The quick way: sign in

Claude, ChatGPT and most coding tools can sign in to Envoi, so there is no key to copy. You pick the workspace and what the app may do, and you can disconnect it any time in Settings, then Connect your AI.

  • Claude (claude.ai, the desktop app and the phone app): add Envoi to Claude, click Connect, and sign in.
  • ChatGPT: at chatgpt.com/plugins, turn on developer mode, then click + > Create app > Create MCP App. Name it Envoi, paste the server URL https://envoi.email/api/mcp, choose OAuth, and sign in. OpenAI says making changes needs a Business, Enterprise or Edu plan.
  • Claude Code: run the command below, then /mcp in Claude Code to sign in.
claude mcp add --transport http --scope user envoi https://envoi.email/api/mcp

Codex: codex mcp add envoi --url https://envoi.email/api/mcp, then codex mcp login envoi. Gemini CLI: gemini mcp add --scope user --transport http envoi https://envoi.email/api/mcp, then /mcp auth envoi. Cursor and VS Code have one-click install links in Settings, then Connect your AI.

Or use an API key

For a client that cannot sign in, a script, or a key your team manages. Everything below uses a key.

  1. Create an API key in Envoi: open Settings, then API keys, click Create key, and choose MCP agent. Copy the key when it appears. It is shown only once, and it starts with env_.
  2. For Claude Desktop only: install Node.js (the LTS version). Claude Desktop uses it to reach Envoi. Claude Code, Codex, Cursor, VS Code, Gemini CLI and Windsurf connect directly and do not need it.

Your key works like a password. Keep it out of emails, chats and shared documents.

Claude Desktop

  1. Open the config file

    In Claude Desktop, open Settings, then Developer, then click Edit Config. A folder opens with a file called claude_desktop_config.json. Open that file in a plain text editor: Notepad on Windows, or TextEdit on a Mac.

  2. If the file is empty, paste this

    If the file is empty or only contains {}, replace everything in it with the block below. Then replace env_PASTE_YOUR_KEY_HERE with your key. Keep the word Bearer and the space after it.

    {
      "mcpServers": {
        "envoi": {
          "command": "npx",
          "args": [
            "-y",
            "mcp-remote",
            "https://envoi.email/api/mcp",
            "--header",
            "Authorization:${ENVOI_AUTH_HEADER}"
          ],
          "env": {
            "ENVOI_AUTH_HEADER": "Bearer env_PASTE_YOUR_KEY_HERE"
          }
        }
      }
    }
  3. If the file already has something in it, add to it

    Keep what is there. If the file already has an "mcpServers" section, add only the "envoi" part inside it, after the servers that are already there, and put a comma after the server before it. Do not add a second "mcpServers".

    If the file has other settings but no "mcpServers", add the whole "mcpServers" part just before the last }, with a comma after the setting before it.

    Here is a complete file that already had another server (called filesystem here), with Envoi added beside it. Your other entries stay exactly as they were:

    {
      "mcpServers": {
        "filesystem": {
          "command": "npx",
          "args": [
            "-y",
            "@modelcontextprotocol/server-filesystem",
            "/Users/you/Desktop"
          ]
        },
        "envoi": {
          "command": "npx",
          "args": [
            "-y",
            "mcp-remote",
            "https://envoi.email/api/mcp",
            "--header",
            "Authorization:${ENVOI_AUTH_HEADER}"
          ],
          "env": {
            "ENVOI_AUTH_HEADER": "Bearer env_PASTE_YOUR_KEY_HERE"
          }
        }
      }
    }
  4. Save, then restart Claude Desktop

    Save the file. Then quit Claude Desktop completely and open it again: on a Mac choose Claude, then Quit Claude (or press Command-Q); on Windows right-click the Claude icon near the clock and choose Quit. Closing the window is not enough.

Claude Code

Run this in a terminal, with your key in place of env_PASTE_YOUR_KEY_HERE. Claude Code connects to Envoi directly, so it does not need Node.js or a config file, and the Envoi tools are then available in every project.

claude mcp add --transport http --scope user envoi https://envoi.email/api/mcp --header "Authorization: Bearer env_PASTE_YOUR_KEY_HERE"

Cursor

In Cursor's settings, open the MCP section and add a new MCP server. That opens mcp.json (~/.cursor/mcp.json for all projects, or .cursor/mcp.json inside one project). Paste this, with your key, using the same rules as steps 2 and 3 above if the file already has servers in it:

{
  "mcpServers": {
    "envoi": {
      "url": "https://envoi.email/api/mcp",
      "headers": {
        "Authorization": "Bearer env_PASTE_YOUR_KEY_HERE"
      }
    }
  }
}

Codex

Run these in a terminal, with your key in place of env_PASTE_YOUR_KEY_HERE. Codex reads the key from ENVOI_API_KEY, so add the first line to your shell profile (~/.zshrc or ~/.bashrc) to keep it across terminals. The ChatGPT desktop app uses the same setup.

export ENVOI_API_KEY="env_PASTE_YOUR_KEY_HERE"
codex mcp add envoi --url https://envoi.email/api/mcp --bearer-token-env-var ENVOI_API_KEY

Or add this to ~/.codex/config.toml:

[mcp_servers.envoi]
url = "https://envoi.email/api/mcp"
bearer_token_env_var = "ENVOI_API_KEY"

VS Code

For GitHub Copilot agent mode. Run MCP: Open User Configuration from the command palette and paste this. VS Code asks for your key the first time Envoi starts and keeps it in its secure storage, not in the file.

{
  "inputs": [
    {
      "type": "promptString",
      "id": "envoi-api-key",
      "description": "Envoi API key",
      "password": true
    }
  ],
  "servers": {
    "envoi": {
      "type": "http",
      "url": "https://envoi.email/api/mcp",
      "headers": {
        "Authorization": "Bearer ${input:envoi-api-key}"
      }
    }
  }
}

Gemini CLI

Run this in a terminal, with your key in place of env_PASTE_YOUR_KEY_HERE.

gemini mcp add --scope user --transport http --header "Authorization: Bearer env_PASTE_YOUR_KEY_HERE" envoi https://envoi.email/api/mcp

Windsurf

Open ~/.codeium/windsurf/mcp_config.json (Cascade, then the MCP icon, then Configure) and paste this, with your key, using the same rules as steps 2 and 3 above if the file already has servers in it:

{
  "mcpServers": {
    "envoi": {
      "serverUrl": "https://envoi.email/api/mcp",
      "headers": {
        "Authorization": "Bearer env_PASTE_YOUR_KEY_HERE"
      }
    }
  }
}

Want fewer tools? https://envoi.email/api/mcp/readonly serves only the tools that read, and https://envoi.email/api/mcp?topics=campaign,audience serves only the topics you name (workspace, email, assets, campaign, audience, form, flow, analytics, approval). Use either URL in place of https://envoi.email/api/mcp in any snippet above.

Check it worked

  • Claude Desktop: start a new chat and open the tools menu under the message box (the icon with sliders). You should see envoi with its tools switched on. Then ask “List my Envoi email templates”. The first time, Claude asks permission to use the Envoi tool; allow it, and your templates appear.
  • Claude Code: run claude mcp list. The envoi line should say it is connected. Inside a session, /mcp shows the same.
  • Cursor: in the MCP settings, envoi should have a green dot and a number of tools next to it.
  • Codex: run codex mcp list. Inside a session, /mcp lists the envoi tools.
  • VS Code: run MCP: List Servers from the command palette. envoi should be running.
  • Gemini CLI: run gemini mcp list, or /mcp inside a session.

Everything Claude creates appears in Envoi straight away, and every call it makes is recorded in your workspace's audit log.

Troubleshooting

Claude Desktop says the config is invalid, or Envoi never shows up

Almost always a JSON typing problem. Look for a missing comma between two entries, an extra comma after the last entry, a missing } at the end, or curly quotes (“ ”) where there should be straight quotes (").

Curly quotes come from word processors and notes apps. Edit the file in a plain text editor instead: Notepad on Windows, or TextEdit on a Mac after choosing Format, then Make Plain Text.

The quickest fix is to start again: replace the whole file with the block from step 2 above. To check a file, run this in a terminal with your own path in the quotes; it prints "valid" or the line with the mistake:

node -p "JSON.parse(require('fs').readFileSync(process.argv[1],'utf8')) && 'valid'" "$HOME/Library/Application Support/Claude/claude_desktop_config.json"

I can't find the config file

In Claude Desktop, Settings, then Developer, then Edit Config opens the folder that holds it.

On a Mac you can also open Finder, choose Go, then Go to Folder, and paste ~/Library/Application Support/Claude. On Windows, press Windows key + R, type %APPDATA%\Claude and press Enter.

If claude_desktop_config.json is not in that folder, create a new plain text file with that exact name and paste in the block from step 2.

Envoi shows as failed, or Claude reports 401 Invalid API key

The key is wrong, revoked or expired, or the word Bearer and the space after it went missing. Keys start with env_.

Create a new key in Envoi under Settings, API keys, choose MCP agent, and paste it in place of the old one. Then quit and reopen Claude Desktop.

Claude reports 403

"API key does not have the required permission": the key was created without the mcp:tools scope. Edit the key in Settings, API keys, and choose MCP agent.

"This API key belongs to a viewer" (or another role): a key can never do more than the person who created it. Ask a workspace owner or admin to change your role, or to connect with their own key.

"no longer a member of its workspace": the person who created the key left the workspace. Create a new key.

Claude reports 429 or 402

429 means the key made more calls in a minute than its rate limit allows. Wait a minute, or raise the limit on the key.

402 means the workspace has used its plan's API allowance for this period. Upgrade the plan or wait for the allowance to reset. Calls that are refused are never counted.

"npx: command not found" or "spawn npx ENOENT"

Claude Desktop cannot find Node.js. Install the LTS version from nodejs.org, then quit and reopen Claude Desktop.

If Node.js is installed through a version manager such as nvm, Claude Desktop may not see it. Run which npx in a terminal and put that full path in place of "npx" in the config.

Claude Desktop's own logs are in ~/Library/Logs/Claude on a Mac and %APPDATA%\Claude\logs on Windows, in the files whose names start with mcp.

REST API (v1)

The same operations the app and Claude use, over plain HTTP at https://envoi.email/api/v1. Every request needs Authorization: Bearer <API key>. Templates, campaigns, approvals and flows are available now; contacts, lists and the rest follow topic by topic. The OpenAPI 3.1 description is public at https://envoi.email/api/v1/openapi.json.

Endpoints

  • GET /templates List email templates, newest first.
  • POST /templates Create an email template. Needs an Idempotency-Key.
  • GET /templates/{id} Get one template with its content and version.
  • PATCH /templates/{id} Change a template. Needs If-Match.
  • GET /workspace The workspace at a glance: setup, work in progress, and what can be done.
  • GET /search Find campaigns, templates, segments, lists, audiences, forms, flows and senders by name.
  • GET /objects/{id} Open anything by id, or resume a ws_ work session.
  • DELETE /objects/{id} Delete a draft campaign or an unused template.
  • GET /reference Merge tags, contact fields, brand variables, block types, integrations, flow steps and events.
  • POST /test-sends Send a test of a template or campaign to a workspace member.
  • POST /sessions/email Start an email: blank, a starter, a copy, or an existing template. Needs an Idempotency-Key.
  • POST /templates/{id}/edits Edit an email with block operations, all or nothing. Needs If-Match.
  • GET /templates/{id}/preview Render an email as it will send, with its issues.
  • GET /assets Find images in the media library and stock photos.
  • POST /assets Import an image from a URL into the media library. Needs an Idempotency-Key.
  • POST /sessions/campaign Start or open an email campaign draft. Needs an Idempotency-Key.
  • PATCH /campaigns/{id} Change an email campaign draft. Needs If-Match.
  • GET /campaigns/{id}/check Dry-run a campaign send: recipients, skips, content, plan usage, approval.
  • POST /campaigns/{id}/send Send or schedule a campaign with an approval. Needs the send scope and an Idempotency-Key.
  • POST /sessions/flow Start a flow, empty or from a recipe, or open an existing one. Needs an Idempotency-Key.
  • PATCH /flows/{id} Change a flow’s trigger, steps and goal, or pause it. Needs If-Match.
  • GET /flows/{id}/check Dry-run a flow: walk a contact or a sample through it, and list what stops it going live.
  • POST /flows/{id}/activate Turn a flow on with an approval. Needs the send scope and an Idempotency-Key.
  • POST /approvals Ask a person to approve a campaign send or turning a flow on. Returns the approval link.
  • GET /contacts Find contacts by name, address, status, list, segment, tag or conditions. Masked without read:pii.
  • POST /contacts Create or update up to 500 contacts, with their lists and tags. Never subscribes anyone.
  • GET /contacts/{id} One contact in full: fields, lists, tags, email and SMS consent, events, flows. Needs read:pii.
  • POST /imports Import a CSV file of contacts as a job; poll it at /objects/{id}. Needs an Idempotency-Key.
  • POST /lists Make a list, rename it, add or remove members. If-Match to change one; an Idempotency-Key to make one.
  • GET /audience/preview Count and sample an audience exactly as a send would count it. Saves nothing.
  • POST /segments Create or update a segment from a condition tree. If-Match to change one; an Idempotency-Key to make one.
  • POST /contact-fields Define a custom contact field: key, label, type and choices. Needs an Idempotency-Key.
  • POST /sessions/form Start or open a signup form draft. Needs an Idempotency-Key.
  • PATCH /forms/{id} Change a signup form. Needs If-Match; a live form also needs the publish scope.
  • GET /forms/{id}/check Check a signup form before it goes live: blockers, advisories, preview link.
  • POST /forms/{id}/publish Put a signup form live and get its embed code, or take it down. Needs the publish scope and If-Match.
  • GET /analytics Results for a campaign, flow, form, segment or list, or the workspace by metric over a time range.
  • GET /webhooks List webhook endpoints and how their deliveries are going. Never includes a secret.
  • POST /webhooks Add a webhook endpoint (its secret is returned once), or change one with If-Match. Needs an Idempotency-Key and the webhooks scope.
  • GET /webhooks/{id} Get one webhook endpoint with its version.
  • DELETE /webhooks/{id} Remove a webhook endpoint and its delivery log.
  • POST /webhooks/{id}/test Send a signed webhook.test event now and see how the endpoint answered.
  • GET /webhooks/{id}/deliveries The delivery log of one endpoint: status, attempts and the last error, for 30 days.
  • POST /webhooks/{id}/deliveries/{delivery_id}/retry Send a failed delivery again, with the same event id.
curl -X POST https://envoi.email/api/v1/templates \
  -H "Authorization: Bearer env_PASTE_YOUR_KEY_HERE" \
  -H "Idempotency-Key: welcome-email-1" \
  -H "Content-Type: application/json" \
  -d '{"name":"Welcome","subject":"Welcome to Acme","blocks":[{"type":"text","props":{"content":"<p>Hi {{first_name}}</p>"}}]}'
curl -X PATCH https://envoi.email/api/v1/templates/TEMPLATE_ID \
  -H "Authorization: Bearer env_PASTE_YOUR_KEY_HERE" \
  -H 'If-Match: "VERSION_FROM_THE_ETAG"' \
  -H "Content-Type: application/json" \
  -d '{"subject":"Welcome aboard"}'

Keys and scopes

Create keys in Settings, API keys. A key belongs to the workspace, not to the person who made it, and keeps working after they leave. Each operation needs one scope:

  • read Look up templates and other workspace data. Contact details stay masked.
  • read:pii See full contact details: email addresses, phone numbers and custom fields. Never on by default.
  • write Create and change drafts: templates, campaigns, contacts, lists, flows. Sends nothing.
  • publish Put things live that send nothing by themselves, such as a signup form. Never on by default.
  • send Send campaigns and turn on flows. Reaches real people. Never on by default.
  • webhooks Add, change, test and remove webhook endpoints. Reading them needs read only. Never on by default.

New keys get read and write. Nobody can give a key a scope their own role could not use. Keys made before the REST API act as the person who made them, with read and write limited by that person's role, and stop working if they leave the workspace.

Versions

/v1 is the major version. Within it, the Envoi-Version header picks a dated version. A key is pinned to the version of its first request, so behaviour only changes when you send a newer date. Every response says which version served it. Released: 2026-10-01 (latest 2026-10-01). An unknown date is refused with 400. A deprecated version keeps working for at least 12 months and says so with Deprecation and Sunset headers; after its sunset date it answers 410.

Requests and responses

  • JSON in and out. Field names are snake_case; an email document (blocks, variables, global_styles) keeps the builder's own format, so a template read here can be written back unchanged. A field the endpoint does not know is refused, never ignored.
  • Lists are newest first, 25 per page by default and at most 100 (limit). Pass next_cursor back as cursor while has_more is true. Pages never skip or repeat an item, even while items are added.
  • Creates and sends need an Idempotency-Key. Retrying with the same key and body within 24 hours returns the first response (with Idempotent-Replayed: true) instead of doing it twice. The same key with a different body is refused with 422; while the first request is still running, 409.
  • Drafts carry a version, also sent as the ETag. A write needs it back as If-Match: without it, 428; if someone changed the draft since, 412 with the current_version, and nothing is saved.
  • A write's answer lists in warnings anything Envoi changed in what you sent to make it a valid email, such as a dropped unknown field.
  • Each key has a per-minute limit (set on the key) and each workspace a shared one. Responses carry RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset; over the limit, 429 with Retry-After.
  • Every response has a Request-Id header. Quote it to support.

Errors

Every error is application/problem+json (RFC 9457) with a stable code, the param at fault when there is one, the request_id, a doc_url pointing below, and a next_action saying what to do. Internal errors never include details.

HTTP/1.1 412 Precondition Failed
Content-Type: application/problem+json

{
  "type": "https://envoi.email/api-docs#error-precondition_failed",
  "title": "Version has changed",
  "status": 412,
  "detail": "The template changed since you read it. Nothing was saved.",
  "code": "precondition_failed",
  "param": "If-Match",
  "request_id": "req_4f1c0a9e2b7d6c3a1e5f8b90",
  "doc_url": "https://envoi.email/api-docs#error-precondition_failed",
  "next_action": "Read template tpl_123 again, re-apply your change to it, and send its new version as If-Match.",
  "current_version": "9b1e..."
}
missing_api_key 401 API key required
Send Authorization: Bearer <API key>.
invalid_api_key 401 Invalid API key
The key is wrong, revoked or expired. Create or rotate one in Settings, API keys.
key_not_workspace_scoped 403 API key has no workspace
An old key with no workspace. Create a new key in the workspace you want to use.
key_owner_not_member 403 API key's creator left the workspace
An older key acts as the person who made it, and they have left the workspace. Create a new key: new keys belong to the workspace.
insufficient_scope 403 API key is missing a scope
The key lacks the scope named in required_scope. An owner or admin can add it in Settings, API keys.
insufficient_permission 403 Your role does not allow this
An older key is limited by its creator's role, and that role cannot do this.
invalid_request 400 Invalid request
A field is missing, the wrong type, or not one the endpoint knows. param names it.
invalid_json 400 Request body is not valid JSON
The body is not valid JSON.
invalid_cursor 400 Invalid cursor
Pass next_cursor exactly as a list returned it, to the same endpoint.
unsupported_api_version 400 Unsupported API version
Envoi-Version must be a released version. supported_versions lists them.
api_version_sunset 410 API version retired
That version is retired. Send a newer Envoi-Version.
resource_missing 404 Not found
Nothing with that id in this workspace.
payload_too_large 413 Request body too large
Bodies are limited to 1 MB.
unsupported_media_type 415 Request body must be JSON
Send Content-Type: application/json.
idempotency_key_required 400 Idempotency-Key required
Creates and sends need an Idempotency-Key header: any unique string up to 255 characters.
invalid_idempotency_key 400 Invalid Idempotency-Key
Use up to 255 printable ASCII characters, no spaces.
idempotency_conflict 422 Idempotency-Key reused with a different request
That Idempotency-Key was already used by another key, with a different body, or on another endpoint. Use a new key for a new request.
idempotency_in_flight 409 Request with this Idempotency-Key is still running
The first request with this key is still running. Retry with the same key after Retry-After.
idempotency_result_unavailable 409 Request already ran; its response was not stored
The request with this key already ran, but its response could not be stored. Read the resource to see what it did; do not retry it.
precondition_required 428 If-Match required
Writes to a draft need If-Match with the version (the ETag) you last read.
precondition_failed 412 Version has changed
Someone changed it since you read it, and nothing was saved. Read it again, re-apply your change, and send the new version.
rate_limited 429 Too many requests
Wait for Retry-After seconds. limit_scope says whether the key, the workspace, or the key’s reports (analytics, 60 a minute) hit their limit.
conflict 409 Conflicts with the current state
The object is not in a state that allows this, such as a campaign that is no longer a draft or a live flow whose steps you tried to change. detail says which.
work_session_expired 410 Work session expired
The ws_ session went 30 days without use. detail names the tool that reopens it; the draft is kept.
work_session_closed 409 Work session closed
The ws_ session was finished or abandoned. Start a new one on the same object.
send_blocked 409 Not ready to send
Something stops the send or the flow going live. blockers lists each one and who can fix it; the campaign or flow check shows them all.
setup_incomplete 409 Workspace setup not finished
The workspace has not finished its setup: its domain, sender or business details. missing lists each row with where it is fixed; a person finishes them at setup_url. Nothing was sent.
list_source_missing 409 A list in the audience has no answered source
The campaign reaches people on a list nobody has said the source of. lists names each one (id and name), and detail says the same in words. A teammate answers how those people joined from the list in Envoi (Answer for this list); an API key or agent cannot. Checked after setup_incomplete. Nothing was sent.
publish_blocked 409 Not ready to publish
The form is not fit to go live. blockers lists each one and who can fix it; form_check shows them all.
approval_required 403 Approval required
A person must approve this send or flow. Ask with POST /approvals and share the link it returns.
approval_unusable 409 Approval cannot be used
The approval cannot be used: already used, rejected, voided by an edit, over its recipient cap, or outside its window. reason says which.
approval_expired 410 Approval expired
Approvals last 24 hours. Ask for a new one.
agent_send_cap 403 Agent send limit reached
This agent reached the workspace's daily limit for sends without approval. Ask a person to approve it.
agent_unsubscribe_cap 403 Agent unsubscribe limit reached
API keys and agents can unsubscribe at most 100 subscribed people in a workspace in any 24 hours, across contact writes and imports. Nothing in the request was changed. A teammate can unsubscribe more people in Envoi.
confirmation_required 409 Confirm the recipient count
Repeat the exact count to go ahead without a person approving it: recipient_count as confirm_recipients for a send, entrants as confirm_entrants for a flow.
send_worker_updating 503 Send worker is updating
Envoi is deploying its send worker, which must be on the current release before an approved send can go out. Nothing was sent. Retry in a few minutes.
storage_not_configured 503 Media storage not configured
Media storage is not set up on this Envoi instance.
email_provider_error 502 Email provider refused the message
The email provider did not accept a test message, so nothing was sent and the campaign is unchanged. Retry shortly; provider_code names the provider’s reason when it gave one.
range_too_wide 400 Date range too wide
A report reads at most about three years, and a grouped series at most 100 points. Shorten the range, or group by a longer period.
report_timeout 503 Report took too long
The report ran past its time limit and was stopped. Ask for less: a shorter range, a longer group_by, fewer metrics, or no compare or people.
validation_error 400 Invalid request
The request was well formed but not allowed. detail says why.
usage_limit_exceeded 402 Plan limit reached
The workspace's plan limit for this is used up.
unverified_sender 400 Sender not verified
Send from a domain verified in this workspace.
url_blocked 400 URL not allowed
That URL points somewhere Envoi will not fetch, such as a private address.
domain_rejected 400 Domain rejected
That domain cannot be used for sending.
domain_already_registered 409 Domain already registered
That domain is already registered to a workspace.
domain_provider_unavailable 503 Domain provider unavailable
The domain provider did not answer. Try again shortly.
agentmail_not_configured 503 Inbound email not configured
Inbound email is not set up on this Envoi instance.
inbox_provider_not_configured 503 Inbound email not configured
Inbound email is not set up on this Envoi instance.
internal_error 500 Internal error
Our fault. Retry, and quote request_id to support if it keeps happening.

Webhooks

Envoi can tell your server when something happens in a workspace. An owner or admin adds an endpoint in Settings, Webhooks, or with POST /api/v1/webhooks, and picks its events. Each event is a signed POST to that URL. URLs must be public https: private, loopback and cloud metadata addresses are refused when the endpoint is saved and again at every delivery.

Events

  • contact.created Someone was added in Envoi, by a form (once they confirm, with double opt-in), or through the API or an agent. data: contact_id, status, source.
  • contact.updated A contact was changed in Envoi or through the API. Names the fields that changed, never their values. data: contact_id, changed, source.
  • contact.unsubscribed A contact opted out of email: the unsubscribe link, one-click, the preferences page, a teammate, the API, or an import or Shopify sync that turned a subscribed contact to unsubscribed. data: contact_id, channel, source, campaign_id.
  • campaign.sent An email or SMS campaign finished sending to everyone it was sent to. data: campaign_id, channel, name, recipients, failed, sent_at.
  • form.submitted A signup form was submitted. What the visitor typed is not included. data: form_id, submission_id, contact_id, contact_created.
  • flow.contact_entered A contact started a flow. data: flow_id, contact_id, execution_id, trigger_step_id.
  • flow.contact_exited A contact finished a flow, reached its goal, stopped at a step that failed, placed an order in a flow set to exit on one, or texted STOP to a question. data: flow_id, contact_id, execution_id, reason.
  • approval.requested Someone, or an agent, asked a teammate to approve a send, a flow going live, or another action. data: approval_id, action_type, subject_type, subject_id, requested_by, expires_at.
  • approval.decided An approval was approved or rejected, by a teammate or by the workspace policy for agent sends. data: approval_id, action_type, subject_type, subject_id, decision, decided_by.
  • sms.opted_out A phone number opted out of texts: a STOP reply, a teammate recording it, or an import, Klaviyo migration or Shopify sync that withdrew an opt-in on record. data: contact_id, brand_id, scope, source.

Payloads name things by id. They never carry an email address, a phone number, a name, a custom field value or what a visitor typed into a form: read those with the REST API if you need them. An endpoint can opt in to the contact's email, phone and name on contact events (include_contact_details), which only someone who could read them through the API may turn on: a teammate who can export contacts, or a key with read:pii. If that person leaves or loses the role, or the key is revoked, the details stop and data.contact_details_withheld is true.

Requests

Headers follow Standard Webhooks: webhook-id (the event id, the same on every retry), webhook-timestamp (when this attempt was signed, in Unix seconds) and webhook-signature. The body is { id, type, timestamp, data }.

POST /envoi-webhooks HTTP/1.1
Content-Type: application/json
User-Agent: Envoi-Webhooks/1.0 (+https://envoi.email/api-docs#webhooks)
webhook-id: evt_5f2c1e0b9a8d7c6b5a4f3e2d1c0b9a8d
webhook-timestamp: 1790000000
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=

{
  "id": "evt_5f2c1e0b9a8d7c6b5a4f3e2d1c0b9a8d",
  "type": "form.submitted",
  "timestamp": "2026-09-21T14:13:20.000Z",
  "data": {
    "form_id": "cmf0form0001",
    "submission_id": "cmf0subm0001",
    "contact_id": "cmf0cont0001",
    "contact_created": true
  }
}

Checking the signature

The signature is an HMAC-SHA256, keyed with the base64 part of your secret, over id.timestamp.body, sent as v1,<base64>. Check it against the raw body before you parse it. Any Standard Webhooks library works; this is the whole check in Node with no packages:

import crypto from 'node:crypto';

// secret: the whsec_... value Envoi showed once, when you added the endpoint.
// rawBody: the request body exactly as it arrived, before any JSON parsing.
function verifyEnvoiWebhook(secret, headers, rawBody) {
  const id = headers['webhook-id'];
  const timestamp = headers['webhook-timestamp'];
  const signatures = headers['webhook-signature'];
  if (!id || !timestamp || !signatures) return false;

  // A request signed more than 5 minutes ago (or ahead) may be a replay.
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;

  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
  const expected = crypto
    .createHmac('sha256', key)
    .update(`${id}.${timestamp}.${rawBody}`)
    .digest();

  // After a rotation there are two signatures for 24 hours: either may match.
  return signatures.split(' ').some((entry) => {
    const [version, signature] = entry.split(',');
    if (version !== 'v1' || !signature) return false;
    const given = Buffer.from(signature, 'base64');
    return given.length === expected.length && crypto.timingSafeEqual(given, expected);
  });
}
app.post('/envoi-webhooks', express.raw({ type: 'application/json' }), (req, res) => {
  const rawBody = req.body.toString('utf8');
  if (!verifyEnvoiWebhook(process.env.ENVOI_WEBHOOK_SECRET, req.headers, rawBody)) {
    return res.sendStatus(400);
  }
  const event = JSON.parse(rawBody);
  // Delivery is at least once: skip an event.id you have already handled.
  // Answer within 10 seconds; do slow work after responding.
  res.sendStatus(204);
});

The secret is shown once, when the endpoint is added. Rotating it returns a new one, and for the next 24 hours every request carries two signatures, one per secret, separated by a space, so you can deploy the new secret without dropping a delivery. A second rotation inside those 24 hours is refused (409, with the time it is allowed again), so the first secret is never dropped early.

Retries and failures

  • Answer with any 2xx within 10 seconds, the connection opened within 3. Anything else is a failure, including a redirect, which is never followed. Envoi records the status code, never your response body.
  • A failed delivery is retried after 1 minute, 5 minutes, 30 minutes, then 2, 5, 8 and 8 hours: eight attempts over about 24 hours. Each delivery is independent, and they can arrive out of order.
  • Delivery is at least once. Keep the id of events you have handled and skip a repeat.
  • After 5 failed attempts in a row, Envoi pauses the endpoint: its waiting deliveries are held, then a single attempt tests it, and the pause grows after each failure. The first success sends everything that waited.
  • When every attempt has failed for about a day, Envoi turns the endpoint off and tells the workspace owner in Activity. Turn it back on in Settings, Webhooks, after a test succeeds.
  • Settings, Webhooks shows each endpoint's deliveries for 30 days, with the status code and error of the last attempt, and retries a failed one on request.

Events API

API keys work in exactly three places: the REST API above (/api/v1), the MCP connection (/api/mcp) and POST /api/events. Every other /api address belongs to the Envoi app itself and needs a signed-in browser session; called with an API key, it answers 401 with a JSON error. The REST API covers emails, email campaigns, flows, approvals, images and the audience: contacts, lists, segments, imports and custom fields, and signup forms. SMS campaigns have no API or MCP tools yet: your team makes them in Envoi.

POST/api/events

Send a server-side event

Send Authorization: Bearer <API key> (or x-api-key) with a key that has the events:write scope. The body needs event, and an email or contactId to attach it to a contact (an unknown email creates the contact), plus any properties. A contact created this way gets no marketing until a form or double opt-in subscribes it. The event can start automations, counts toward the key's rate limit, and is written to the API request log. A success answers 202 with {"ok":true}.

curl -X POST https://envoi.email/api/events \
  -H "Authorization: Bearer env_PASTE_YOUR_KEY_HERE" \
  -H "Content-Type: application/json" \
  -d '{"event":"purchase.completed","email":"buyer@example.com","properties":{"orderId":"ord_1"}}'

Website tracking and forms

These endpoints need no API key: they identify your workspace with the siteId of a Tracking Snippet integration, and accept calls from any website. Most sites only need the snippet below; the Tracking Snippet page in Integrations shows yours with the site id filled in.

<script src="https://envoi.email/envoi.js" data-site-id="YOUR_SITE_ID"></script>
<script>
  // When someone signs up or logs in, with the signature your server computed:
  envoi.identify('buyer@example.com', { signature: '<hex signature>', firstName: 'Jane' });
  // Anything else worth reacting to:
  envoi.track('product.viewed', { productId: 'hat_1' });
</script>
POST/api/public/page

Record a page view

Accepts siteId, visitorId, url, referrer, and title. Stores a page.viewed event for reports, segments and goals. Page views never start an automation.

POST/api/public/track

Record a custom event

Accepts siteId, visitorId, event, properties, url, and referrer. Event names Envoi records itself, such as form.submitted or contact.created, are refused with a 400. An email property never starts an automation: a new address becomes a pending contact with the event on it, and an existing contact the visitor has not proved to be gets nothing, the event staying with the anonymous visitor. A new address gets no marketing until a form or double opt-in subscribes it.

POST/api/public/identify

Identify a visitor

Accepts siteId, visitorId, email, properties, and proof of identity: signature (hex HMAC-SHA256 of the lowercased email, keyed with the site identity secret from the Tracking Snippet page, computed on your server) or identityToken (from an Envoi email link or form). With proof, the visitor and their earlier activity are linked to the contact and automations can start. Without it, a new address still becomes a pending contact, but an existing contact is never reached. A wrong, stale or foreign signature or token is refused with a 401. Answers 202 with verified true or false. A new address gets no marketing until a form or double opt-in subscribes it.

GET/api/public/forms/{formId}/embed.js

Load a form embed

Serves the hosted form script. It shares the _envoi_vid visitor id with website tracking.

POST/api/public/forms/{formId}/submit

Submit a public form

Validates required fields, creates or updates the contact, records the submission, and writes a form.submitted event.

Copy-paste examples

Track an anonymous visitor

fetch('https://envoi.email/api/public/track', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    siteId: 'YOUR_SITE_ID',
    visitorId: 'visitor_...',
    event: 'product.viewed',
    url: 'https://shop.example/products/hat',
    properties: { productId: 'hat_1' }
  })
})

Identify and stitch a visitor to a contact

fetch('https://envoi.email/api/public/identify', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    siteId: 'YOUR_SITE_ID',
    visitorId: 'visitor_...',
    email: 'buyer@example.com',
    // hex HMAC-SHA256 of the lowercased email, keyed with the site identity secret
    signature: '<hex signature>',
    properties: { firstName: 'Buyer' }
  })
})

Submit a hosted form with visitor context

fetch('https://envoi.email/api/public/forms/form_123/submit', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    fields: { email: 'buyer@example.com' },
    metadata: { visitorId: 'visitor_...', source: 'landing-page' }
  })
})

Receive an integration webhook

curl -X POST https://envoi.email/api/webhooks/int_123 \
  -H "x-envoi-signature: sha256=..." \
  -H "Content-Type: application/json" \
  -d '{"event":"order.created","email":"buyer@example.com"}'

Integrations and webhooks

  • /api/integrations: list and create integrations (signed-in session).
  • /api/integrations/{id}: read, update or delete one integration. Stored credentials are never returned.
  • /api/integrations/{id}/test: run the connection check and save the result.
  • /api/webhooks/{integrationId}: receive events for a custom integration, verified with its webhook secret.
  • /api/integrations/shopify/install and /callback: start and finish connecting a Shopify store.

Webhook security

Each provider webhook (Stripe, Resend, Twilio and the rest) checks the provider's signature before it accepts an event. A custom integration receives events at its own webhook URL, verified with its own secret, and the integration's page lists the events it received.

Technical details

MCP protocol

Envoi speaks the Model Context Protocol at POST /api/mcp (https://envoi.email/api/mcp): JSON-RPC 2.0 over Streamable HTTP, JSON response mode, stateless. Protocol revision 2026-07-28; 2025-11-25, 2025-06-18, 2025-03-26 and 2024-11-05 are also accepted. Authenticate with Authorization: Bearer <API key> or an x-api-key header, or with an access token from signing in (below). A refused call is a 401 whose WWW-Authenticate: Bearer header carries resource_metadata, which is how a client finds out it can sign in. Every connection needs the mcp:tools scope. What a tool may do is then decided exactly as on the REST API: every tool needs the key's API scope for it (read, read:pii, write, publish or send). A key can never do more than the person who created it could give it, and an older key that acts as its creator is also held to their current role. Over the key's per-minute rate limit the endpoint returns 429. Each tool call counts as one API call against the plan; connecting, listing tools and reading resources or prompts do not. When the plan's API calls are used up, a tool call returns a tool error saying so. Refused calls are never billed.

Claude Desktop runs local commands only, so it connects through the mcp-remote bridge with the header passed as Authorization:${ENVOI_AUTH_HEADER} and its value in env, which keeps the space after “Bearer” intact on Windows.

Signing in (OAuth 2.1)

Envoi is its own authorization server, following the MCP authorization spec. Protected resource metadata (RFC 9728) is at https://envoi.email/.well-known/oauth-protected-resource/api/mcp (and /api/mcp/readonly), and authorization server metadata (RFC 8414) at https://envoi.email/.well-known/oauth-authorization-server. Clients are public: the authorization code flow with PKCE S256 is required, and the token endpoint takes no client secret. A client identifies itself with a client ID metadata document (an HTTPS URL as its client_id) or registers dynamically (RFC 7591). Redirect URIs must match exactly, except that a loopback address may use any port. Pass resource (RFC 8707) with the MCP URL: a token only works on the endpoint it was issued for, and a token for /api/mcp also works on /api/mcp/readonly. Scopes are read, write, read:pii, publish and send, and never more than the approving person's role allows, checked again on every call.

Access tokens last one hour. Refresh tokens rotate on every use. A spent refresh token sent again within 10 seconds is refused, so a retry is safe; sent later, it disconnects the app. A refresh may ask for fewer scopes than were approved, and the next one for all of them again. A connection lasts 180 days, then the person approves the app again. Tokens work on /api/mcp only, not the REST API. A connected app is metered, rate limited and audited like an API key. It stops working the moment it is disconnected, its approver leaves the workspace, or its approver resets or changes their password or signs out everywhere.

A signed-in app lists only the tools it was allowed (on protocol 2025-11-25 and earlier, which Claude, ChatGPT and Claude Code use). A tool call that needs a scope the app was not given is a tool error that names the scope, never an HTTP 403 challenge: only the person can allow more, by connecting the app again.

Tools

tools/list advertises exactly the tools below, and every one of them runs. Each is one of the REST API's operations: the same input, the same result as structuredContent, and the same errors. Every edit takes the expected_version it read, and a create or send takes an optional idempotency_key, so a retry never does the work twice. campaign_send needs an approval_id from approval_request, or, where the workspace lets agents send, confirm_recipients equal to the count campaign_check returned; either way the send goes through the same checks as the Send button in the app (verified sender, unsubscribes and suppressions, frequency caps, plan limits). There are no bulk delete tools.

  • analytics_report
  • approval_request
  • assets_import
  • assets_search
  • audience_contact_get
  • audience_contacts_find
  • audience_contacts_upsert
  • audience_field_define
  • audience_import
  • audience_list_edit
  • audience_preview
  • audience_segment_save
  • campaign_check
  • campaign_edit
  • campaign_send
  • campaign_start
  • email_edit
  • email_preview
  • email_start
  • flow_activate
  • flow_check
  • flow_edit
  • flow_start
  • form_check
  • form_edit
  • form_publish
  • form_start
  • workspace_delete
  • workspace_get
  • workspace_overview
  • workspace_reference
  • workspace_search
  • workspace_test_send

Resources

resources/read serves builder documentation, block JSON Schemas, and the workspace's default brand guidelines.

  • envoi://docs/email-blocks
  • envoi://docs/email-patterns
  • envoi://docs/example-templates
  • envoi://docs/merge-tags
  • envoi://docs/email-builder-workflow
  • envoi://schema/blocks
  • envoi://brand-guidelines/default

Prompts

prompts/get returns guided workflows for building, refining and auditing an email.

  • build-email: Build a complete email template of a given type from a brief.
  • refine-email: Apply feedback to an existing template without rebuilding it.
  • audit-email: Audit a template for send-readiness and report findings without editing.

API-key scopes

ScopeGroupGrants
events:writeEventsSend server-side track, identify and page events to POST /api/events.
events:*EventsEvery current and future action in the events namespace.
mcp:toolsMCPConnect an MCP client (Claude Desktop, Cursor) and use the workspace, email, image, campaign, flow, approval, audience, signup form and results tools. What they may change, publish or send is set by the API scopes below.

API-key lifecycle

Keys are managed at /settings/api-keys, or at /api/api-keys from a signed-in session with api_keys:manage. Create a hashed key whose plaintext secret is shown once (the settings page gives new keys a 90-day expiry unless you choose otherwise), PATCH /api/api-keys/{id} to edit name, scopes, rate limit, or expiry, POST /api/api-keys/{id}/rotate to mint a replacement with an optional grace window, and DELETE /api/api-keys/{id} to revoke it. Scopes support the namespace wildcard events:*. Every request is logged, redacted, at /api/logs?type=api for roles with developer:manage.

POST /api/api-keys        (dashboard session, api_keys:manage)
{
  "name": "Claude Desktop",
  "permissions": ["mcp:tools"],
  "scopes": ["read", "write"],
  "rateLimit": 1000,
  "expiresAt": "2027-01-01T00:00:00.000Z"
}
// -> 201 { "apiKey": { ...metadata }, "key": "<the secret, starting env_>" }  (plaintext shown once)

App endpoints (signed-in session only)

These read tracking data back into the app. They need a signed-in session in the workspace and do not accept API keys.

  • /api/analytics: campaign and website activity metrics.
  • /api/contacts/{id}/activity: one contact’s timeline of page views and events.
  • /api/segments/preview: preview which contacts a segment matches.
  • /api/logs?type=webhooks|ai|usage|email|sms|api: the workspace audit and debug feed, for roles with developer:manage.