# Quickstart MarleyFetch sends email through an SMTP account you already own. You tell it how to log in to that account once, then send with one HTTPS request from anywhere — no SMTP library, no open sockets. You need about five minutes and the SMTP login for the address you want to send from. ## 1. Sign in Go to [app.marleyfetch.com](https://app.marleyfetch.com) and sign in with Google. Signing in only identifies you; it does not give MarleyFetch access to your mailbox. ## 2. Add an SMTP connection Open **[Connections](https://app.marleyfetch.com/connections) → Add SMTP Connection**, pick your provider and enter the address and password. - **Gmail and Google Workspace** need an [App Password](https://myaccount.google.com/apppasswords), not your normal password. Turn on 2-Step Verification first. - **Microsoft 365 / Outlook, Yahoo, iCloud, Zoho** also need an app password or SMTP AUTH enabled. - **SES, SendGrid, Brevo** use the SMTP credentials from their console. MarleyFetch tests the login before saving, so a wrong password fails here rather than on your first send. See [SMTP connections](https://docs.marleyfetch.com/guides/connections.md) for every provider's settings. ## 3. Create an API key Open **[API Keys](https://app.marleyfetch.com/tokens) → Create Key**. Copy the key — it starts with `mf_live_` and is shown only once. Store it as an environment variable, never in source control: ```bash export MARLEYFETCH_API_KEY="mf_live_..." ``` ## 4. Send an email Replace `you@yourcompany.com` with the address you connected and `you@example.com` with where the test should go. ::: code-group ```bash [cURL] curl https://api.marleyfetch.com/v1/send \ -H "Authorization: Bearer $MARLEYFETCH_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "from": "you@yourcompany.com", "to": "you@example.com", "subject": "Hello from MarleyFetch", "text_body": "It works." }' ``` ```ts [SDK] // npm install @marleyfetch/sdk import { MarleyFetch } from '@marleyfetch/sdk' const mf = new MarleyFetch({ apiKey: process.env.MARLEYFETCH_API_KEY }) await mf.from('you@yourcompany.com') .to('you@example.com') .subject('Hello from MarleyFetch') .text_body('It works.') .send() ``` ```ts [fetch] const res = await fetch('https://api.marleyfetch.com/v1/send', { method: 'POST', headers: { Authorization: `Bearer ${process.env.MARLEYFETCH_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ from: 'you@yourcompany.com', to: 'you@example.com', subject: 'Hello from MarleyFetch', text_body: 'It works.', }), }) console.log(res.status, await res.json()) ``` ```python [Python] import os, requests res = requests.post( "https://api.marleyfetch.com/v1/send", headers={"Authorization": f"Bearer {os.environ['MARLEYFETCH_API_KEY']}"}, json={ "from": "you@yourcompany.com", "to": "you@example.com", "subject": "Hello from MarleyFetch", "text_body": "It works.", }, ) print(res.status_code, res.json()) ``` ::: A successful response looks like this: ```json { "success": true, "messageId": "", "outboxId": 4821, "from": "you@yourcompany.com", "message": "Email sent successfully" } ``` `from` is the address of the connection you added in step 2 — it picks which account sends. Your first connection is also your default, so you could leave `from` out; keeping it means the request still sends from the same address after you add more connections. ## 5. Check the outbox Every send appears in the dashboard's **[Email Outbox](https://app.marleyfetch.com/outbox)** with its status and any error from your SMTP server. With mailbox providers such as Gmail it also shows up in your Sent folder, because it really was sent from your account. ## Next steps - [Sending email](https://docs.marleyfetch.com/guides/sending.md) — recipients, HTML, idempotent retries and queued sends. - [Batch sending](https://docs.marleyfetch.com/guides/batches.md) — one message to up to thousands of recipients. - [Errors](https://docs.marleyfetch.com/reference/errors.md) — what each error code means and what to do about it. - [API reference](https://docs.marleyfetch.com/api/index.md) — every endpoint and field. Source: https://docs.marleyfetch.com/getting-started --- # JavaScript SDK `@marleyfetch/sdk` wraps the API in a chainable builder. It has no dependencies, uses the runtime's native `fetch`, and weighs about 1 kB gzipped, so it runs anywhere `fetch` does: Node.js 18+, Cloudflare Workers, Vercel and Netlify functions, Deno and Bun. ```bash npm install @marleyfetch/sdk ``` Source, issues and releases: [github.com/marleyfetch/sdk](https://github.com/marleyfetch/sdk). ## Send an email ```ts import { MarleyFetch } from '@marleyfetch/sdk' const mf = new MarleyFetch({ apiKey: process.env.MARLEYFETCH_API_KEY }) const { messageId } = await mf .from('you@yourcompany.com') .to('alice@example.com', 'bob@example.com') .cc('manager@example.com') .subject('Monthly report') .text_body('Numbers are up.') .html_body('

Numbers are up.

') .send() ``` Every setter is named after the API field it sets and returns the builder, so you can call them in any order. Nothing is sent until the chain ends with: | Method | Calls | Returns | | --- | --- | --- | | `.send()` | `POST /v1/send` | after delivery, with `messageId` | | `.enqueue()` | `POST /v1/enqueue` (Pro) | as soon as the email is queued | | `.build()` | nothing | the request body — handy in tests | `to`, `cc` and `bcc` accept several arguments, arrays or comma-separated strings, and add up across calls. ## Send a batch ```ts const { batch_id } = await mf.batch() .name('Renewal reminder') .template_id(3) // or .subject() / .text_body() / .html_body() .audience_id(12) // or .recipients(['a@example.com', ...]) .send() const { batch } = await mf.getBatch(batch_id) console.log(batch.status, `${batch.sent_count}/${batch.total_count}`) await mf.deleteBatch(batch_id) // once it is completed or failed ``` See [Batch sending](https://docs.marleyfetch.com/guides/batches.md) for how batches pace themselves and personalise content. ## Handle errors Any non-2xx response throws a `MarleyFetchError`: ```ts import { MarleyFetch, MarleyFetchError } from '@marleyfetch/sdk' try { await mf.to('a@example.com').subject('Hi').text_body('Hi').send() } catch (error) { if (error instanceof MarleyFetchError) { console.error(error.status, error.code, error.message) if (error.code === 'RATE_LIMITED' && error.retryAfter) { // wait error.retryAfter seconds, then retry } } throw error } ``` `error.code` is one of the codes in [Errors](https://docs.marleyfetch.com/reference/errors.md). Network failures throw whatever the runtime's `fetch` throws. An incomplete email (no recipient, no subject, no body) throws a plain `Error` from `.build()` before any request is made. The SDK does not send an `Idempotency-Key` yet. If you need [safe retries](https://docs.marleyfetch.com/guides/sending.md#safe-retries-with-idempotency-key) on `/send`, call the API with `fetch` directly. ## Reading the API key | Runtime | | | --- | --- | | Node.js, Vercel, Netlify | `process.env.MARLEYFETCH_API_KEY` | | Cloudflare Workers | the `env` argument: `env.MARLEYFETCH_API_KEY` (set with `wrangler secret put`) | | Deno | `Deno.env.get('MARLEYFETCH_API_KEY')`, and import from `npm:@marleyfetch/sdk` | | Bun | `Bun.env.MARLEYFETCH_API_KEY` | On Cloudflare Workers, create the client inside the handler, where `env` is available: ```ts import { MarleyFetch } from '@marleyfetch/sdk' export default { async fetch(request: Request, env: { MARLEYFETCH_API_KEY: string }) { const mf = new MarleyFetch({ apiKey: env.MARLEYFETCH_API_KEY }) await mf.to('ops@yourcompany.com').subject('Worker alert').text_body('Something happened').send() return new Response('sent') }, } ``` ## Smaller imports `MarleyFetch` pulls in both builders. If you only send single emails, import the pieces (about 800 bytes gzipped): ```ts import { createTransport } from '@marleyfetch/sdk/core' import { EmailBuilder } from '@marleyfetch/sdk/email' const transport = createTransport({ apiKey: process.env.MARLEYFETCH_API_KEY }) await new EmailBuilder(transport).to('a@example.com').subject('Hello').text_body('Hi').send() ``` `createTransport` returns a plain `(method, path, body) => Promise` function, which is easy to stub in tests. The package is ESM-only. Source: https://docs.marleyfetch.com/sdk --- # SMTP connections A **connection** is the SMTP login for one sending address. Every email MarleyFetch sends goes out through one of your connections, so it is delivered by your provider, from your domain, with your reputation. Add connections on the [Connections](https://app.marleyfetch.com/connections) page. The login is tested before it is saved, and the password is encrypted at rest. ## Provider settings Pick a preset and most fields fill themselves in. For reference: | Provider | Host | Port | Username | Password | | --- | --- | --- | --- | --- | | Gmail | `smtp.gmail.com` | 587 | your address | [App Password](https://myaccount.google.com/apppasswords) (needs 2-Step Verification) | | Google Workspace | `smtp.gmail.com` | 587 | your address | App Password; your admin must allow SMTP | | Outlook / Hotmail | `smtp-mail.outlook.com` | 587 | your address | account password | | Microsoft 365 | `smtp.office365.com` | 587 | your address | account password; SMTP AUTH must be enabled for the mailbox | | Yahoo Mail | `smtp.mail.yahoo.com` | 465 | your address | App Password | | iCloud Mail | `smtp.mail.me.com` | 587 | your address | app-specific password from appleid.apple.com | | Zoho Mail | `smtp.zoho.com` | 465 | your address | account or app password | | SendGrid | `smtp.sendgrid.net` | 587 | the literal word `apikey` | your SendGrid API key | | Brevo | `smtp-relay.brevo.com` | 587 | your address | SMTP key from Brevo → SMTP & API | | Amazon SES | `email-smtp..amazonaws.com` | 587 | SES SMTP username | SES SMTP password (not your AWS access key) | | Any other host | your host | 587, 465 or 2525 | usually your address | your password | Only the standard submission ports are accepted: **587** and **2525** use STARTTLS, **465** uses TLS. Encryption follows from the port, so you never set it separately. ::: tip Most failed connections are the password Gmail, Workspace, Yahoo and iCloud reject your normal password over SMTP. Create an app password and use that. Microsoft 365 tenants often have SMTP AUTH switched off until an admin enables it. ::: ## Choosing the sender: `from` Every send request can include `from`. It must be the address of one of your connections, and it decides which connection sends: ```json { "from": "billing@yourcompany.com", "to": "customer@example.com", "subject": "Invoice", "text_body": "..." } ``` - **With `from`** — MarleyFetch sends through the connection with that address, or returns `403 CONNECTION_NOT_FOUND` if you have none. - **Without `from`** — it uses your **default connection**. Your first connection becomes the default automatically; pick another on the Connections page (**⋮ → Make Default**). If you delete the default, your oldest remaining connection takes over. An [API key](https://docs.marleyfetch.com/guides/api-keys.md) can also be locked to one connection, which overrides both. ## Quotas and pacing Each connection has a **quota**: how many emails it may send per day or per hour. Presets start at the provider's published limit (for example 500/day for Gmail, 2,000/day for Workspace), and you can change the number and the window when you add or edit the connection. The Connections page shows how much of it is used. Keep the quota at or below what your provider allows. Going over their limit gets your account throttled or suspended by the provider, not by MarleyFetch. What happens when a limit is hit: | Situation | `/send` | `/enqueue` and batches | | --- | --- | --- | | Connection quota used up | `429 QUOTA_EXCEEDED` with `details.resetsAt` | Wait for the next window, then send | | Sending too fast, or the provider pushed back | `429 RATE_LIMITED` with `details.retryAfter` (seconds) | Back off and retry automatically | | Provider kept rejecting sends, so the connection was paused | `403 CONNECTION_NOT_FOUND` or `CONNECTION_PAUSED` | Wait until it resumes | | You disabled the connection | `403 CONNECTION_NOT_FOUND` | Not sent | The Free plan also caps sending at 100 emails a day (`429 DAILY_LIMIT_REACHED`); see [Plans & limits](https://docs.marleyfetch.com/reference/limits.md). ## Disabling and deleting Disable a connection on the Connections page to stop all sending through it without losing its settings, and enable it again later. API keys locked to a deleted connection stop working. ## Plan limits The Free plan allows **1** connection; Pro is unlimited. Source: https://docs.marleyfetch.com/guides/connections --- # API keys Every API request carries an API key in the `Authorization` header: ```http Authorization: Bearer mf_live_3f9a... ``` Manage keys on the [API Keys](https://app.marleyfetch.com/tokens) page. ## Creating a key Click **Create Key** and choose: - **Name** — shown in the dashboard and next to every email the key sends, so you can tell your apps apart. - **Connection Scope** — *All connections*, or one connection. See below. - **Expiration Period** — never, or after 7 to 365 days. The full key is shown **once**. Copy it into your secret store straight away; MarleyFetch keeps only a hash and cannot show it again. ## Locking a key to one connection A key scoped to a connection can only send through that connection: - Requests without `from` use the scoped connection, whatever your default is. - A `from` that belongs to a different connection is refused with `403 CONNECTION_SCOPE_MISMATCH`. Give each app its own scoped key: a leaked key can then only send as that one address. You can change a key's scope later with **Edit Scope**. ## Rotating and revoking - **Rotate** issues a new secret for the key; the old one stops working. Update your app with the new value. - **Revoke** disables the key permanently. Requests with a revoked, expired or unknown key get `401` with `{"error": "Invalid or expired API token"}`. ## Keeping keys safe - Read the key from the environment: `process.env` on Node.js and Vercel, the `env` argument on Cloudflare Workers, `Deno.env.get()` on Deno, `Bun.env` on Bun. - Never ship a key to a browser or a mobile app — anyone could read it and send as you. Call MarleyFetch from your server. - Never commit a key. If one leaks, revoke it and create a new one. ## Plan limits The Free plan allows **1** API key; Pro is unlimited. Source: https://docs.marleyfetch.com/guides/api-keys --- # Sending email There are two ways to send a single email. Both take the same body. | | `POST /v1/send` | `POST /v1/enqueue` | | --- | --- | --- | | Returns | after your SMTP server accepts the email | as soon as the email is queued | | Response includes | `messageId` and `outboxId` | `outboxId` and `queuedAt` | | On a temporary failure | you get the error and decide | retried in the background | | Plan | Free and Pro | Pro | Use `/send` when the caller needs to know the email went out — a password reset, a login code. Use `/enqueue` when it should not wait — a welcome email after sign-up, a notification from a request handler. For one message to many people, use a [batch](https://docs.marleyfetch.com/guides/batches.md). ## The request body ```json { "from": "you@yourcompany.com", "to": ["alice@example.com", "bob@example.com"], "cc": "manager@example.com", "bcc": "archive@yourcompany.com", "subject": "Weekly report", "text_body": "Numbers are up.", "html_body": "

Weekly report

Numbers are up.

" } ``` | Field | Required | | | --- | --- | --- | | `to` | yes | Recipients. A single address, a comma-separated string, or an array. | | `subject` | yes | Non-empty. | | `text_body` | one of the two | Plain-text body. | | `html_body` | one of the two | HTML body. Send `text_body` too — some clients and spam filters prefer it. | | `from` | no | Address of one of your [connections](https://docs.marleyfetch.com/guides/connections.md#choosing-the-sender-from). Omit to use your default connection. | | `cc`, `bcc` | no | Same formats as `to`. | `/send` accepts at most **10 recipients** in total across `to`, `cc` and `bcc`; more returns `400 TOO_MANY_RECIPIENTS`. To reach more people, send a [batch](https://docs.marleyfetch.com/guides/batches.md) — it also sends each recipient their own copy instead of exposing the whole list in `to`. ## `POST /v1/send` ::: code-group ```bash [cURL] curl https://api.marleyfetch.com/v1/send \ -H "Authorization: Bearer $MARLEYFETCH_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: reset-password-8f3a2c" \ -d '{ "from": "security@yourcompany.com", "to": "user@example.com", "subject": "Reset your password", "text_body": "Use this link to reset your password: https://...", "html_body": "

Use this link to reset your password.

" }' ``` ```ts [SDK] await mf.from('security@yourcompany.com') .to('user@example.com') .subject('Reset your password') .text_body('Use this link to reset your password: https://...') .html_body('

Use this link to reset your password.

') .send() ``` ::: Response: ```json { "success": true, "messageId": "", "outboxId": 4821, "from": "security@yourcompany.com", "message": "Email sent successfully" } ``` If your SMTP server rejects the message, you get a `500` whose `message` is the server's reason, for example `535 5.7.8 Username and Password not accepted`. The failed attempt does not count against your quota. ### Safe retries with `Idempotency-Key` A request can time out after the email was sent. Retrying blindly would send it twice. Add an `Idempotency-Key` header — any string unique to this email, such as a UUID or `order-1042-shipped` — and reuse it on retries: - If the first request **succeeded**, the retry returns its result with `"replayed": true`. Nothing is sent again. - If the first request **failed**, the retry returns that failure with `"success": false` and `"replayed": true`. Use a new key to try again. - If the first request is **still running**, the retry gets `400 IDEMPOTENCY_KEY_IN_FLIGHT`. Wait a moment and retry with the same key. Keys are scoped to your account. `Idempotency-Key` is supported on `/send`. ## `POST /v1/enqueue` ::: code-group ```bash [cURL] curl https://api.marleyfetch.com/v1/enqueue \ -H "Authorization: Bearer $MARLEYFETCH_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": "new-user@example.com", "subject": "Welcome aboard", "html_body": "

Thanks for signing up.

" }' ``` ```ts [SDK] await mf.to('new-user@example.com') .subject('Welcome aboard') .html_body('

Thanks for signing up.

') .enqueue() ``` ::: Response: ```json { "success": true, "message": "Email queued successfully", "outboxId": 4822, "from": "you@yourcompany.com", "queuedAt": "2026-09-28T10:15:00.000Z" } ``` A background worker sends the email, usually within seconds, and retries it if your provider is briefly unavailable or rate-limiting. The API has no status endpoint for single emails yet; follow them in the [Email Outbox](https://app.marleyfetch.com/outbox) by `outboxId`. `/enqueue` is a Pro feature. On the Free plan it returns `403 FEATURE_NOT_AVAILABLE`. ## Limits - `/send`: at most 10 recipients per request, and the Free plan's 100 emails a day. - Both endpoints: the connection's own [quota](https://docs.marleyfetch.com/guides/connections.md#quotas-and-pacing). `/send` answers `429` when it is used up; queued emails wait for the next window instead. See [Errors](https://docs.marleyfetch.com/reference/errors.md) for every error code and how to handle it. Source: https://docs.marleyfetch.com/guides/sending --- # Batch sending A batch sends the same message to many people, each getting their own email. You make one request; MarleyFetch works through the list in the background, pacing itself to your connection's quota. Batch sending is a **Pro** feature. ## Create a batch `POST /v1/send/batch` needs **content** and **recipients**: | Content — pick one | | | --- | --- | | `template` | Inline `{ subject, text_body?, html_body? }`. | | `template_id` | ID of a saved [template](https://docs.marleyfetch.com/guides/templates.md). | | Recipients — pick one | | | --- | --- | | `recipients` | Array of up to 1,000 addresses. | | `audience_id` | ID of a saved [audience](https://docs.marleyfetch.com/guides/audiences.md). Enables personalisation. | Optional: `name` (shown in the dashboard) and `from` (which [connection](https://docs.marleyfetch.com/guides/connections.md) sends). When each recipient needs different content, send an [`emails`](#different-content-per-recipient) array instead. ### To a list of addresses ::: code-group ```bash [cURL] curl https://api.marleyfetch.com/v1/send/batch \ -H "Authorization: Bearer $MARLEYFETCH_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "September newsletter", "from": "news@yourcompany.com", "template": { "subject": "What is new in September", "html_body": "

Here is what we shipped this month.

" }, "recipients": ["alice@example.com", "bob@example.com"] }' ``` ```ts [SDK] const { batch_id } = await mf.batch() .name('September newsletter') .from('news@yourcompany.com') .subject('What is new in September') .html_body('

Here is what we shipped this month.

') .recipients(['alice@example.com', 'bob@example.com']) .send() ``` ::: ### To an audience, personalised When you send to an audience, `{{placeholders}}` in the subject and bodies are replaced with each member's `payload`: ```json { "name": "Renewal reminder", "template": { "subject": "{{first_name}}, your {{plan}} plan renews soon", "html_body": "

Hi {{first_name}}, your {{plan}} plan renews next week.

" }, "audience_id": 12 } ``` A member added with `"payload": {"first_name": "Alice", "plan": "Pro"}` receives *"Alice, your Pro plan renews soon"*. Placeholders are case-sensitive, and a variable the member does not have renders as an empty string — so a sentence like `Hi {{first_name}},` degrades to `Hi ,`. Keep payloads complete for every variable you use. Recipients passed as a plain `recipients` array have no payload, so leave placeholders out of those batches. ### Different content per recipient `emails` takes up to 1,000 fully formed emails — one recipient, subject and body each — and replaces both the content and the recipients: ```json { "name": "Order updates", "emails": [ { "to": "alice@example.com", "subject": "Order 1042 has shipped", "text_body": "Your order is on its way." }, { "to": "bob@example.com", "subject": "Order 1043 is delayed", "html_body": "

Sorry, your order is a day late.

" } ] } ``` Each item goes to exactly one address; `cc` and `bcc` are rejected. For a handful of emails, calling [`/send`](https://docs.marleyfetch.com/guides/sending.md) for each works just as well. ## The response The request returns as soon as the batch is created: ```json { "success": true, "batch_id": 77, "total_count": 2, "message": "2 emails queued for batch delivery.", "status_url": "/v1/batches/77" } ``` ## Track progress Poll `GET /v1/batches/{id}`: ```bash curl https://api.marleyfetch.com/v1/batches/77 \ -H "Authorization: Bearer $MARLEYFETCH_API_KEY" ``` ```json { "success": true, "batch": { "id": 77, "name": "September newsletter", "status": "running", "total_count": 500, "sent_count": 320, "failed_count": 2, "progress": 64, "created_at": "2026-09-28 10:15:00", "updated_at": "2026-09-28 10:21:40" } } ``` `status` moves from `pending` to `running` to `completed` (or `failed`). `progress` is the percentage of recipients handled, sent or failed. Once a minute is plenty for polling; the [Batches](https://app.marleyfetch.com/batches) page shows the same numbers, plus each failed recipient with its error and a retry button. ## How pacing works A batch never breaks your connection's limits: - It sends only as fast as the connection allows. - When the connection's quota is used up, the remaining emails **wait for the next window** and continue — a 5,000-recipient batch on a 2,000/day connection takes three days, and nothing fails. - If your provider starts rate-limiting, the batch backs off and resumes. Only permanent problems, such as a deleted connection or an address your server refuses, count as failures. ## Delete a finished batch ```bash curl -X DELETE https://api.marleyfetch.com/v1/batches/77 \ -H "Authorization: Bearer $MARLEYFETCH_API_KEY" ``` Only `completed` or `failed` batches can be deleted; others return `400 INVALID_STATUS`. ## Errors specific to batches | Status | Body | Cause | | --- | --- | --- | | 400 | `BATCH_TOO_LARGE` | More than 1,000 `recipients` or `emails`. Use an audience instead. | | 400 | `{"error": "Invalid audience"}` | `audience_id` does not exist or is not yours. | | 400 | `{"error": "Audience has no members"}` | The audience is empty. | | 404 | `RESOURCE_NOT_FOUND` | `template_id` does not exist or is not yours. | | 403 | `FEATURE_NOT_AVAILABLE` | Batches need the Pro plan. | Source: https://docs.marleyfetch.com/guides/batches --- # Audiences An **audience** is a saved list of recipients. Each member has an email address and an optional `payload` of variables that fill `{{placeholders}}` when you send the audience a [batch](https://docs.marleyfetch.com/guides/batches.md#to-an-audience-personalised). Audiences are a **Pro** feature. Manage them on the [Audiences](https://app.marleyfetch.com/audiences) page or through the API. ## Create an audience ```bash curl https://api.marleyfetch.com/v1/audiences \ -H "Authorization: Bearer $MARLEYFETCH_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name": "Beta customers", "description": "Everyone on the beta programme"}' ``` ```json { "success": true, "audience": { "id": 12, "name": "Beta customers", "description": "Everyone on the beta programme", "...": "..." } } ``` ## Add members Up to 10,000 members per request. Addresses already in the audience are skipped. ```bash curl https://api.marleyfetch.com/v1/audiences/12/members \ -H "Authorization: Bearer $MARLEYFETCH_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "members": [ { "email": "alice@example.com", "payload": { "first_name": "Alice", "plan": "Pro" } }, { "email": "bob@example.com", "payload": { "first_name": "Bob", "plan": "Free" } } ] }' ``` ```json { "success": true, "inserted": 2, "duplicates": 0, "message": "Added 2 members (0 duplicates skipped)" } ``` In the dashboard, **Add Members** takes one member per line. Add variables after a comma as `key=value`: ```text alice@example.com, first_name=Alice, plan=Pro bob@example.com, first_name=Bob carol@example.com ``` Values without a key are stored as `attr_1`, `attr_2`, … in order, so prefer `key=value` for anything you want to use in a template. ## List and remove members ```bash # 100 per page by default, up to 1,000 curl "https://api.marleyfetch.com/v1/audiences/12/members?limit=100&offset=0" \ -H "Authorization: Bearer $MARLEYFETCH_API_KEY" # Remove one member curl -X DELETE https://api.marleyfetch.com/v1/audiences/12/members/345 \ -H "Authorization: Bearer $MARLEYFETCH_API_KEY" ``` The list response has a `pagination` object with `total`, `limit`, `offset` and `hasMore`. Keep increasing `offset` by `limit` while `hasMore` is `true`. ## Other operations | | | | --- | --- | | `GET /v1/audiences` | All your audiences with `member_count`, newest first. | | `GET /v1/audiences/{id}` | One audience with `member_count`. | | `PUT /v1/audiences/{id}` | Change `name` or `description`. | | `DELETE /v1/audiences/{id}` | Delete the audience. | ## Sign-up pages Each audience can have a hosted sign-up page where people add themselves. Set it up from the audience's page in the dashboard: choose a URL slug, title, description, button label and success message, then share the link. It is protected by a Cloudflare Turnstile challenge against bots. Members who join this way get `{"source": "public_signup_page", "slug": "..."}` as their payload. ## Send to an audience ```json { "template_id": 3, "audience_id": 12 } ``` `POST` that to `/v1/send/batch` — see [Batch sending](https://docs.marleyfetch.com/guides/batches.md). Source: https://docs.marleyfetch.com/guides/audiences --- # Templates A **template** is a saved subject plus a text and/or HTML body. Create them on the [Templates](https://app.marleyfetch.com/templates) page or through the API, then send one by ID in a [batch](https://docs.marleyfetch.com/guides/batches.md) with `template_id`. The Free plan can keep **3** templates; Pro is unlimited. ## Create a template ```bash curl https://api.marleyfetch.com/v1/templates \ -H "Authorization: Bearer $MARLEYFETCH_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Renewal reminder", "subject": "{{first_name}}, your {{plan}} plan renews soon", "html_body": "

Hi {{first_name}}, your {{plan}} plan renews next week.

", "text_body": "Hi {{first_name}}, your {{plan}} plan renews next week." }' ``` `name` and `subject` are required, plus at least one of `text_body` and `html_body`. When you are at your plan's limit, the API returns `403` with `"error": "Template limit reached"`. ## Placeholders Write `{{variable}}` anywhere in the subject or bodies. When the template is sent to an [audience](https://docs.marleyfetch.com/guides/audiences.md), each placeholder is replaced with that member's `payload` value. - Names are case-sensitive: `{{First_Name}}` and `{{first_name}}` are different. - Spaces inside the braces are ignored: `{{ first_name }}` works. - A variable the member does not have becomes an empty string. Placeholders are only filled from data in batches sent to an audience. In a batch to a plain `recipients` list every placeholder becomes empty, and `/send` sends them literally — so keep placeholders out of both. ## Send a template ```bash curl https://api.marleyfetch.com/v1/send/batch \ -H "Authorization: Bearer $MARLEYFETCH_API_KEY" \ -H "Content-Type: application/json" \ -d '{"template_id": 3, "audience_id": 12}' ``` ## Other operations | | | | --- | --- | | `GET /v1/templates` | All your templates. | | `GET /v1/templates/{id}` | One template. | | `PUT /v1/templates/{id}` | Change any of `name`, `subject`, `text_body`, `html_body`. | | `DELETE /v1/templates/{id}` | Delete it. Batches already created are not affected. | Source: https://docs.marleyfetch.com/guides/templates --- # Forms A **form** is a hosted page — a contact form, a feedback form, a bug report — that you share as a link. Each submission is saved in the dashboard and emailed to you. No code and no API key are involved; you build it on the [Forms](https://app.marleyfetch.com/forms) page. Forms are available on every plan. ## Create a form On the Forms page, click to create one and set: - **Slug** — the last part of the URL: lowercase letters, digits and dashes. - **Name** and **description**. The description supports Markdown. - **Connection** — which of your connections receives the submissions. - **Fields** — each with a label and a type: short text, email, long text or a dropdown, and whether it is required. - **Submit button label** and **success message**, optionally. The form is published at a link of the form `https://app.marleyfetch.com/f//`, shown on the form's page in the dashboard. ## What happens on submit 1. The visitor passes a Cloudflare Turnstile check, which keeps bots out. 2. Required fields and email fields are validated. 3. The submission is saved; you can read every submission on the form's page. 4. An email with all the answers is sent **from your connection to itself**, so it arrives in that mailbox like any other message. Form emails show up in your [Email Outbox](https://app.marleyfetch.com/outbox) like the rest of your sends. Source: https://docs.marleyfetch.com/guides/forms --- # API reference Base URL `https://api.marleyfetch.com/v1`. Authenticate every request with `Authorization: Bearer ` from the [API Keys](https://app.marleyfetch.com/tokens) page. The machine-readable spec is at [/openapi.yaml](https://docs.marleyfetch.com/openapi.yaml). For walkthroughs, start with the [Quickstart](https://docs.marleyfetch.com/getting-started.md). Source: https://docs.marleyfetch.com/api/ --- # Errors Errors use standard HTTP status codes. Most bodies look like this: ```json { "code": "RATE_LIMITED", "message": "Account you@yourcompany.com is pacing sends. Retry in 4s.", "details": { "retryAfter": 4 } } ``` Branch on `code`; `message` is written for humans and may change. `details` is present only when there is something useful to add. Two other shapes appear: - **Authentication failures and some audience/template checks** return `{"error": ""}` with no `code`. - **Body validation failures** return `400` with the list of problems: ```json { "success": false, "error": { "name": "ZodError", "issues": [{ "code": "invalid_type", "path": ["subject"], "message": "Required" }] } } ``` A robust client checks the status first, then reads `code`, falling back to `error`. ## Error codes ### 400 Bad Request | Code | Cause | Fix | | --- | --- | --- | | *(validation body)* | A required field is missing or has the wrong type, or neither `text_body` nor `html_body` was sent. | Read `error.issues[].path` and `.message`. | | `TOO_MANY_RECIPIENTS` | More than 10 recipients across `to`, `cc` and `bcc` on `/send`. | Send a [batch](https://docs.marleyfetch.com/guides/batches.md). | | `BATCH_TOO_LARGE` | More than 1,000 `recipients` in a batch. | Put them in an [audience](https://docs.marleyfetch.com/guides/audiences.md) and send with `audience_id`. | | `IDEMPOTENCY_KEY_IN_FLIGHT` | An earlier request with the same `Idempotency-Key` is still running. | Wait a moment and retry with the same key. | | `INVALID_ID` | The ID in the URL is not a number. | — | | `INVALID_STATUS` | Deleting a batch that is still `pending` or `running`. | Wait until it finishes. | ### 401 Unauthorized | Body | Cause | Fix | | --- | --- | --- | | `{"error": "Missing or invalid Authorization header"}` | No `Authorization: Bearer ...` header. | Add the header. | | `{"error": "Invalid or expired API token"}` | The key is wrong, revoked or expired. | Check the key on the [API Keys](https://app.marleyfetch.com/tokens) page; create a new one if needed. | ### 403 Forbidden | Code | Cause | Fix | | --- | --- | --- | | `NO_CONNECTION` | No `from` was sent and you have no connections yet. | Add one on the [Connections](https://app.marleyfetch.com/connections) page. | | `CONNECTION_NOT_FOUND` | `from` is not one of your connections, or that connection is disabled or paused. | Check the address and the connection's status. | | `CONNECTION_SCOPE_MISMATCH` | The key is [locked to a connection](https://docs.marleyfetch.com/guides/api-keys.md#locking-a-key-to-one-connection) and `from` is a different one. | Drop `from` or use the right key. | | `CONNECTION_PAUSED` | Your provider kept rejecting sends, so the connection was paused. | Wait for it to resume, or check it in the dashboard. | | `FEATURE_NOT_AVAILABLE` | The endpoint needs the Pro plan. `details.feature` names it. | [Upgrade](https://app.marleyfetch.com/account). | | *(`{"error": "Access denied"}`)* | The audience or template belongs to another account. | — | | *(`{"error": "Template limit reached"}`)* | Your plan's template limit is reached. | Delete a template or upgrade. | ### 404 Not Found | Code | Cause | | --- | --- | | `BATCH_NOT_FOUND` | No batch with this ID on your account. | | `RESOURCE_NOT_FOUND` | `template_id` in a batch does not exist. | | *(`{"error": "Audience not found"}` etc.)* | No audience, member or template with this ID. | ### 429 Too Many Requests | Code | Cause | When to retry | | --- | --- | --- | | `RATE_LIMITED` | The connection is pacing sends, or your SMTP provider pushed back. | After `details.retryAfter` seconds. | | `QUOTA_EXCEEDED` | The connection's own daily or hourly [quota](https://docs.marleyfetch.com/guides/connections.md#quotas-and-pacing) is used up. | At `details.resetsAt`. | | `DAILY_LIMIT_REACHED` | The Free plan's 100 emails a day. | Tomorrow (00:00 UTC), or upgrade. | ### 500 Internal Server Error | Code | Cause | | --- | --- | | `INTERNAL_ERROR` | Usually your SMTP server refused the message or could not be reached; `message` carries its reason, e.g. `535 5.7.8 Username and Password not accepted`. Nothing was sent. | ## Retrying - **No response at all** (timeout, dropped connection): retry with the same [`Idempotency-Key`](https://docs.marleyfetch.com/guides/sending.md#safe-retries-with-idempotency-key). If the first attempt was sent, you get its result back instead of a second email. - **`429`**: wait `details.retryAfter` seconds (or until `details.resetsAt`), then retry. - **`500`**: your SMTP server refused the message. Retrying the same request rarely helps; fix the cause in `message` first. A retry with the *same* key replays the failure as `200` with `"success": false` — use a new key for a new attempt. - **Other `4xx`**: the request itself is wrong. Do not retry without changing it. ```ts async function send(email: object, idempotencyKey: string, attempts = 3) { for (let attempt = 1; ; attempt++) { let res: Response try { res = await fetch('https://api.marleyfetch.com/v1/send', { method: 'POST', headers: { Authorization: `Bearer ${process.env.MARLEYFETCH_API_KEY}`, 'Content-Type': 'application/json', 'Idempotency-Key': idempotencyKey, }, body: JSON.stringify(email), }) } catch (networkError) { if (attempt === attempts) throw networkError continue } const data = await res.json() if (res.ok && data.success) return data if (res.status !== 429 || attempt === attempts) { throw new Error(`${res.status} ${data.code ?? ''} ${data.message ?? data.error}`) } await new Promise(resolve => setTimeout(resolve, (data.details?.retryAfter ?? 5) * 1000)) } } ``` Or use [`/enqueue`](https://docs.marleyfetch.com/guides/sending.md#post-v1-enqueue) and let MarleyFetch do the retrying. The [SDK](https://docs.marleyfetch.com/sdk.md) throws a `MarleyFetchError` with `status`, `code`, `message` and `retryAfter`. Source: https://docs.marleyfetch.com/reference/errors --- # Plans & limits ## Plans | | Free | Pro | | --- | --- | --- | | Price | $0 | $29 / month | | SMTP connections | 1 | Unlimited | | API keys | 1 | Unlimited | | Emails per day | 100 | No platform cap — your provider's limits apply | | `POST /v1/send` | ✓ | ✓ | | `POST /v1/enqueue` | — | ✓ | | `POST /v1/send/batch` | — | ✓ | | Audiences | — | ✓ | | Templates | 3 | Unlimited | | Forms | ✓ | ✓ | | Email log retention | 7 days | 90 days | Upgrade or manage your subscription on the [Account](https://app.marleyfetch.com/account) page. Using a Pro-only endpoint on the Free plan returns `403 FEATURE_NOT_AVAILABLE`. ## Request limits These apply on every plan. | Limit | Value | | --- | --- | | Recipients per `/send` request (`to` + `cc` + `bcc`) | 10 | | `recipients` per batch | 1,000 (use an audience for more) | | Members added per request | 10,000 | | Audience members per list request | 1,000 (default 100) | | Template subject | 998 characters | | Audience and template names | 255 characters | ## Connection quotas Separately from your plan, each [connection](https://docs.marleyfetch.com/guides/connections.md#quotas-and-pacing) has its own daily or hourly quota that you set to match your provider. It is enforced on every plan. Source: https://docs.marleyfetch.com/reference/limits --- # Docs for AI tools These docs are published in formats that coding assistants and LLMs read well, following the [llms.txt](https://llmstxt.org) convention. | File | What it is | Use it when | | --- | --- | --- | | [`/llms.txt`](https://docs.marleyfetch.com/llms.txt) | An index: one line per page with a short description and a link to its Markdown. | A tool should discover pages and fetch only what it needs. | | [`/llms-full.txt`](https://docs.marleyfetch.com/llms-full.txt) | Every page plus the full OpenAPI spec in one Markdown file. | You want to paste the whole of MarleyFetch into a chat or a context window. | | `/.md` | Any page as raw Markdown, e.g. [`/guides/sending.md`](https://docs.marleyfetch.com/guides/sending.md). | You want one topic. Every page also has **Copy page as Markdown** at the top. | | [`/openapi.yaml`](https://docs.marleyfetch.com/openapi.yaml) | The OpenAPI 3.1 spec for every public endpoint. | Generating a client, or giving a tool exact request and response schemas. | ## Using them **In a chat** — paste the URL or the contents: ```text Read https://docs.marleyfetch.com/llms-full.txt, then write a Cloudflare Worker that sends a welcome email through MarleyFetch when it receives a POST to /signup. ``` **In Cursor** — type `@Docs`, choose *Add new doc* and enter `https://docs.marleyfetch.com/llms-full.txt`. **In Claude Code, Copilot and other agents** — point the agent at a page's Markdown, or put a line in your project's instructions file (`CLAUDE.md`, `AGENTS.md`, `.github/copilot-instructions.md`): ```md Email is sent with MarleyFetch. API docs: https://docs.marleyfetch.com/llms.txt ``` **For code generation** — feed `/openapi.yaml` to your OpenAPI generator of choice. ## Keep keys out of prompts Never paste an API key into a chat. Tell the assistant to read it from an environment variable such as `MARLEYFETCH_API_KEY`. Source: https://docs.marleyfetch.com/ai --- # OpenAPI spec Source: https://docs.marleyfetch.com/openapi.yaml ```yaml openapi: 3.1.0 info: title: MarleyFetch API version: 1.0.0 summary: Send email through your own SMTP account with one HTTPS call. description: | MarleyFetch is an email API in front of the SMTP account you already have. Every request is authenticated with an API key and sent through one of your SMTP connections. - Guides and a quickstart: https://docs.marleyfetch.com - This spec as a file: https://docs.marleyfetch.com/openapi.yaml - Everything in one file for LLMs: https://docs.marleyfetch.com/llms-full.txt **Authentication.** Create an API key at https://app.marleyfetch.com/tokens and send it as `Authorization: Bearer mf_live_...`. **Choosing a sender.** Set `from` to the address of one of your connections. Omit it and your default connection is used. contact: name: MarleyFetch support email: support@marleyfetch.com url: https://docs.marleyfetch.com servers: - url: https://api.marleyfetch.com/v1 description: Production security: - bearerAuth: [] tags: - name: Email description: Send a single email now, or queue it for background delivery. - name: Batches description: Send one message to many recipients. Pro plan. - name: Audiences description: Reusable recipient lists with per-member variables. Pro plan. - name: Templates description: Saved subjects and bodies you can send by ID. Free plan allows 3. paths: /send: post: tags: [Email] operationId: sendEmail summary: Send an email description: | Sends immediately and waits for your SMTP server to accept the message. - Up to **10 recipients** across `to`, `cc` and `bcc`. Use `/send/batch` for more. - Counts towards your plan's daily limit (Free: 100/day) and the connection's quota. - Send an `Idempotency-Key` header to make retries safe: a repeat with the same key returns the original result (with `replayed: true`) instead of sending a second email. parameters: - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/EmailRequest' examples: text: summary: Plain text value: from: you@yourcompany.com to: customer@example.com subject: Your order has shipped text_body: Your order is on its way. html: summary: HTML with cc and bcc value: to: [alice@example.com, bob@example.com] cc: manager@example.com bcc: [archive@yourcompany.com] subject: Weekly report html_body:

Weekly report

Numbers are up.

text_body: Weekly report. Numbers are up. responses: '200': description: The SMTP server accepted the message, or this is a replay of an earlier request with the same `Idempotency-Key`. content: application/json: schema: $ref: '#/components/schemas/SendResponse' examples: sent: summary: Sent value: success: true messageId: outboxId: 4821 from: you@yourcompany.com message: Email sent successfully replayed: summary: Replayed idempotent request value: success: true messageId: outboxId: 4821 from: you@yourcompany.com message: Email already sent for this Idempotency-Key replayed: true '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/ConnectionError' '429': $ref: '#/components/responses/RateLimited' '500': $ref: '#/components/responses/SendFailed' x-codeSamples: - lang: cURL source: | curl https://api.marleyfetch.com/v1/send \ -H "Authorization: Bearer $MARLEYFETCH_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: order-1042-shipped" \ -d '{ "from": "you@yourcompany.com", "to": "customer@example.com", "subject": "Your order has shipped", "text_body": "Your order is on its way." }' - lang: JavaScript label: SDK source: | import { MarleyFetch } from '@marleyfetch/sdk' const mf = new MarleyFetch({ apiKey: process.env.MARLEYFETCH_API_KEY }) const { messageId } = await mf .from('you@yourcompany.com') .to('customer@example.com') .subject('Your order has shipped') .text_body('Your order is on its way.') .send() - lang: JavaScript label: fetch source: | const res = await fetch('https://api.marleyfetch.com/v1/send', { method: 'POST', headers: { Authorization: `Bearer ${process.env.MARLEYFETCH_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ from: 'you@yourcompany.com', to: 'customer@example.com', subject: 'Your order has shipped', text_body: 'Your order is on its way.', }), }) if (!res.ok) throw new Error(`MarleyFetch ${res.status}: ${await res.text()}`) const { messageId } = await res.json() - lang: Python source: | import os, requests res = requests.post( "https://api.marleyfetch.com/v1/send", headers={"Authorization": f"Bearer {os.environ['MARLEYFETCH_API_KEY']}"}, json={ "from": "you@yourcompany.com", "to": "customer@example.com", "subject": "Your order has shipped", "text_body": "Your order is on its way.", }, ) res.raise_for_status() print(res.json()["messageId"]) /enqueue: post: tags: [Email] operationId: enqueueEmail summary: Queue an email description: | Accepts the email and returns straight away; a background worker sends it, retrying on transient failures. Use it when the caller should not wait on your SMTP server. **Pro plan.** There is no status endpoint for a queued email yet: follow it in the dashboard's Email Outbox using the returned `outboxId`. requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/EmailRequest' example: to: customer@example.com subject: Welcome aboard html_body:

Thanks for signing up.

responses: '200': description: Queued. content: application/json: schema: $ref: '#/components/schemas/EnqueueResponse' example: success: true message: Email queued successfully outboxId: 4822 from: you@yourcompany.com queuedAt: '2026-09-28T10:15:00.000Z' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/ConnectionOrPlanError' '429': $ref: '#/components/responses/RateLimited' x-codeSamples: - lang: cURL source: | curl https://api.marleyfetch.com/v1/enqueue \ -H "Authorization: Bearer $MARLEYFETCH_API_KEY" \ -H "Content-Type: application/json" \ -d '{"to": "customer@example.com", "subject": "Welcome aboard", "html_body": "

Thanks for signing up.

"}' - lang: JavaScript label: SDK source: | await mf.to('customer@example.com') .subject('Welcome aboard') .html_body('

Thanks for signing up.

') .enqueue() /send/batch: post: tags: [Batches] operationId: createBatch summary: Send a batch description: | Sends one message to many recipients, one email per recipient. The request returns as soon as the batch is created; poll `status_url` for progress. Give it content — inline `template` **or** a saved `template_id` — and recipients — `recipients` (up to 1,000 addresses) **or** an `audience_id`. Or send `emails`: up to 1,000 items, each with its own recipient, subject and body. With an audience, `{{variable}}` placeholders in the subject and bodies are filled from each member's `payload`. A missing variable renders as an empty string. Batches respect your connection's quota and pacing: when the quota runs out, remaining emails wait for the next window instead of failing. **Pro plan.** requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/BatchRequest' examples: recipients: summary: Inline template to a list of addresses value: name: September newsletter from: news@yourcompany.com template: subject: What's new in September html_body:

Here's what we shipped this month.

recipients: [alice@example.com, bob@example.com] audience: summary: Saved template to an audience, personalised value: name: Renewal reminder template_id: 3 audience_id: 12 emails: summary: Different content per recipient value: name: Order updates emails: - to: alice@example.com subject: Order 1042 has shipped text_body: Your order is on its way. - to: bob@example.com subject: Order 1043 is delayed text_body: Sorry, your order is a day late. responses: '200': description: Batch created and started. content: application/json: schema: $ref: '#/components/schemas/BatchCreatedResponse' example: success: true batch_id: 77 total_count: 2 message: 2 emails queued for batch delivery. status_url: /v1/batches/77 '400': description: | Invalid body, `BATCH_TOO_LARGE` (more than 1,000 `recipients` or `emails`), or an audience that does not exist or has no members. content: application/json: schema: oneOf: - $ref: '#/components/schemas/ValidationErrorResponse' - $ref: '#/components/schemas/Error' - $ref: '#/components/schemas/SimpleError' examples: tooLarge: value: code: BATCH_TOO_LARGE message: Batch too large. Maximum 1000 recipients per batch allowed. emptyAudience: value: error: Audience has no members '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/ConnectionOrPlanError' '404': description: '`template_id` does not exist or belongs to another account.' content: application/json: schema: $ref: '#/components/schemas/Error' example: code: RESOURCE_NOT_FOUND message: Template 3 not found '429': $ref: '#/components/responses/RateLimited' x-codeSamples: - lang: cURL source: | curl https://api.marleyfetch.com/v1/send/batch \ -H "Authorization: Bearer $MARLEYFETCH_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "September newsletter", "template": {"subject": "What'\''s new in September", "html_body": "

Here is what we shipped.

"}, "recipients": ["alice@example.com", "bob@example.com"] }' - lang: JavaScript label: SDK source: | const { batch_id } = await mf.batch() .name('Renewal reminder') .template_id(3) .audience_id(12) .send() /batches/{id}: parameters: - $ref: '#/components/parameters/Id' get: tags: [Batches] operationId: getBatch summary: Get batch status responses: '200': description: Current progress. content: application/json: schema: type: object required: [success, batch] properties: success: { type: boolean, const: true } batch: { $ref: '#/components/schemas/Batch' } example: success: true batch: id: 77 name: September newsletter status: running total_count: 500 sent_count: 320 failed_count: 2 progress: 64 created_at: '2026-09-28 10:15:00' updated_at: '2026-09-28 10:21:40' '400': $ref: '#/components/responses/InvalidId' '401': $ref: '#/components/responses/Unauthorized' '404': $ref: '#/components/responses/BatchNotFound' delete: tags: [Batches] operationId: deleteBatch summary: Delete a batch description: Deletes a finished batch and its per-recipient records. Only `completed` or `failed` batches can be deleted. responses: '200': $ref: '#/components/responses/Deleted' '400': description: Invalid ID, or the batch is still `pending` or `running` (`INVALID_STATUS`). content: application/json: schema: $ref: '#/components/schemas/Error' example: code: INVALID_STATUS message: Cannot delete batch that is still in progress '401': $ref: '#/components/responses/Unauthorized' '404': $ref: '#/components/responses/BatchNotFound' /audiences: get: tags: [Audiences] operationId: listAudiences summary: List audiences responses: '200': description: Your audiences, newest first, with member counts. content: application/json: schema: type: object required: [success, audiences] properties: success: { type: boolean, const: true } audiences: type: array items: { $ref: '#/components/schemas/AudienceWithCount' } '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/ProRequired' post: tags: [Audiences] operationId: createAudience summary: Create an audience requestBody: required: true content: application/json: schema: type: object required: [name] properties: name: { type: string, minLength: 1, maxLength: 255 } description: { type: string, maxLength: 1000 } example: name: Beta customers description: Everyone on the beta programme responses: '201': description: Created. content: application/json: schema: $ref: '#/components/schemas/AudienceResponse' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/ProRequired' /audiences/{id}: parameters: - $ref: '#/components/parameters/Id' get: tags: [Audiences] operationId: getAudience summary: Get an audience responses: '200': description: The audience with its member count. content: application/json: schema: type: object required: [success, audience] properties: success: { type: boolean, const: true } audience: { $ref: '#/components/schemas/AudienceWithCount' } '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/NotYoursOrProRequired' '404': $ref: '#/components/responses/NotFound' put: tags: [Audiences] operationId: updateAudience summary: Update an audience requestBody: required: true content: application/json: schema: type: object properties: name: { type: string, minLength: 1, maxLength: 255 } description: { type: string, maxLength: 1000 } responses: '200': description: Updated. content: application/json: schema: $ref: '#/components/schemas/AudienceResponse' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/NotYoursOrProRequired' '404': $ref: '#/components/responses/NotFound' delete: tags: [Audiences] operationId: deleteAudience summary: Delete an audience responses: '200': $ref: '#/components/responses/Deleted' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/NotYoursOrProRequired' '404': $ref: '#/components/responses/NotFound' /audiences/{id}/members: parameters: - $ref: '#/components/parameters/Id' get: tags: [Audiences] operationId: listAudienceMembers summary: List members parameters: - name: limit in: query schema: { type: integer, default: 100, maximum: 1000 } - name: offset in: query schema: { type: integer, default: 0 } responses: '200': description: One page of members. content: application/json: schema: type: object required: [success, members, pagination] properties: success: { type: boolean, const: true } members: type: array items: { $ref: '#/components/schemas/AudienceMember' } pagination: type: object required: [total, limit, offset, hasMore] properties: total: { type: integer } limit: { type: integer } offset: { type: integer } hasMore: { type: boolean } '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/NotYoursOrProRequired' '404': $ref: '#/components/responses/NotFound' post: tags: [Audiences] operationId: addAudienceMembers summary: Add members description: Adds up to 10,000 members per request. Addresses already in the audience are skipped and counted as `duplicates`. requestBody: required: true content: application/json: schema: type: object required: [members] properties: members: type: array minItems: 1 maxItems: 10000 items: type: object required: [email] properties: email: { type: string, format: email } payload: type: object additionalProperties: true description: Variables for `{{placeholders}}` when this audience is used in a batch. example: members: - email: alice@example.com payload: { first_name: Alice, plan: Pro } - email: bob@example.com payload: { first_name: Bob, plan: Free } responses: '201': description: Members added. content: application/json: schema: type: object required: [success, inserted, duplicates, message] properties: success: { type: boolean, const: true } inserted: { type: integer } duplicates: { type: integer } message: { type: string } example: success: true inserted: 2 duplicates: 0 message: Added 2 members (0 duplicates skipped) '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/NotYoursOrProRequired' '404': $ref: '#/components/responses/NotFound' /audiences/{id}/members/{memberId}: parameters: - $ref: '#/components/parameters/Id' - name: memberId in: path required: true schema: { type: integer } delete: tags: [Audiences] operationId: deleteAudienceMember summary: Remove a member responses: '200': $ref: '#/components/responses/Deleted' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/NotYoursOrProRequired' '404': $ref: '#/components/responses/NotFound' /templates: get: tags: [Templates] operationId: listTemplates summary: List templates responses: '200': description: Your templates. content: application/json: schema: type: object required: [success, templates] properties: success: { type: boolean, const: true } templates: type: array items: { $ref: '#/components/schemas/Template' } '401': $ref: '#/components/responses/Unauthorized' post: tags: [Templates] operationId: createTemplate summary: Create a template description: Free plans can keep 3 templates; Pro is unlimited. requestBody: required: true content: application/json: schema: type: object required: [name, subject] description: At least one of `text_body` or `html_body` is required. properties: name: { type: string, minLength: 1, maxLength: 255 } subject: { type: string, minLength: 1, maxLength: 998 } text_body: { type: string } html_body: { type: string } example: name: Renewal reminder subject: '{{first_name}}, your {{plan}} plan renews soon' html_body:

Hi {{first_name}}, your plan renews next week.

responses: '201': description: Created. content: application/json: schema: $ref: '#/components/schemas/TemplateResponse' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': description: Your plan's template limit is reached. content: application/json: schema: type: object properties: error: { type: string } limit: { type: integer } used: { type: integer } upgradeRequired: { type: boolean } message: { type: string } example: error: Template limit reached limit: 3 used: 3 upgradeRequired: true message: You have 3 templates. Your plan allows 3. Upgrade to Pro for unlimited templates. /templates/{id}: parameters: - $ref: '#/components/parameters/Id' get: tags: [Templates] operationId: getTemplate summary: Get a template responses: '200': description: The template. content: application/json: schema: $ref: '#/components/schemas/TemplateResponse' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/NotYours' '404': $ref: '#/components/responses/NotFound' put: tags: [Templates] operationId: updateTemplate summary: Update a template requestBody: required: true content: application/json: schema: type: object properties: name: { type: string, minLength: 1, maxLength: 255 } subject: { type: string, minLength: 1, maxLength: 998 } text_body: { type: string } html_body: { type: string } responses: '200': description: Updated. content: application/json: schema: $ref: '#/components/schemas/TemplateResponse' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/NotYours' '404': $ref: '#/components/responses/NotFound' delete: tags: [Templates] operationId: deleteTemplate summary: Delete a template responses: '200': $ref: '#/components/responses/Deleted' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/NotYours' '404': $ref: '#/components/responses/NotFound' components: securitySchemes: bearerAuth: type: http scheme: bearer description: | An API key from https://app.marleyfetch.com/tokens, e.g. `Authorization: Bearer mf_live_...`. The key is shown once when created. A key can be limited to a single connection. parameters: Id: name: id in: path required: true schema: { type: integer } IdempotencyKey: name: Idempotency-Key in: header required: false schema: { type: string } description: | Any unique string, e.g. a UUID or `order-1042-shipped`. A retry with the same key returns the first request's result instead of sending again. While the first request is still in progress, a retry gets `400 IDEMPOTENCY_KEY_IN_FLIGHT`. schemas: Recipients: description: One address, a comma-separated string of addresses, or an array of addresses. oneOf: - type: string examples: ['alice@example.com', 'alice@example.com, bob@example.com'] - type: array items: { type: string, format: email } EmailRequest: type: object required: [to, subject] description: At least one of `text_body` or `html_body` is required. Send both for the best deliverability. properties: from: type: string format: email description: Address of one of your connections. Picks the connection to send through; omit to use your default connection. to: { $ref: '#/components/schemas/Recipients' } cc: { $ref: '#/components/schemas/Recipients' } bcc: { $ref: '#/components/schemas/Recipients' } subject: { type: string, minLength: 1 } text_body: { type: string } html_body: { type: string } SendResponse: type: object required: [success, outboxId, message] properties: success: type: boolean description: '`false` only on a replay of an earlier request that failed.' messageId: type: string description: The Message-ID of the sent email. outboxId: type: integer description: ID of the send in your dashboard's Email Outbox. from: { type: string, format: email } message: { type: string } replayed: type: boolean description: Present and `true` when this response is for an earlier request with the same `Idempotency-Key`. EnqueueResponse: type: object required: [success, message, outboxId, queuedAt] properties: success: { type: boolean, const: true } message: { type: string } outboxId: type: integer description: ID of the send in your dashboard's Email Outbox. from: { type: string, format: email } queuedAt: { type: string, format: date-time } BatchRequest: type: object description: | Provide content (`template` or `template_id`, not both) and recipients (`recipients` or `audience_id`) — or an `emails` array, which carries both. properties: name: type: string description: Shown in the dashboard. Defaults to `Batch Send `. from: type: string format: email description: Address of one of your connections. Omit to use your default connection. template: type: object required: [subject] description: Inline content. At least one of `text_body` or `html_body` is required. properties: subject: { type: string, minLength: 1 } text_body: { type: string } html_body: { type: string } template_id: type: integer description: ID of a saved template. recipients: type: array maxItems: 1000 items: { type: string, format: email } audience_id: type: integer description: ID of a saved audience. Members' `payload` fills `{{placeholders}}`. emails: type: array maxItems: 1000 description: One email per item, each with its own content. Use instead of a template and recipients. items: type: object required: [to, subject] additionalProperties: false description: A single recipient — no cc or bcc. At least one of `text_body` or `html_body` is required. properties: to: { type: string, format: email } subject: { type: string, minLength: 1 } text_body: { type: string } html_body: { type: string } BatchCreatedResponse: type: object required: [success, batch_id, total_count, message, status_url] properties: success: { type: boolean, const: true } batch_id: { type: integer } total_count: { type: integer } message: { type: string } status_url: type: string description: Path to poll with `GET`, relative to `https://api.marleyfetch.com`. Batch: type: object required: [id, name, status, total_count, sent_count, failed_count, progress] properties: id: { type: integer } name: { type: string } status: type: string enum: [pending, running, completed, failed] total_count: { type: integer } sent_count: { type: integer } failed_count: { type: integer } progress: type: integer minimum: 0 maximum: 100 description: Percentage of recipients handled, sent or failed. created_at: { type: string } updated_at: { type: string } Audience: type: object properties: id: { type: integer } user_id: { type: integer } name: { type: string } description: { type: [string, 'null'] } temporary: type: integer description: '`1` for the throwaway audiences created behind `recipients` batches.' created_at: { type: string } updated_at: { type: string } AudienceWithCount: allOf: - $ref: '#/components/schemas/Audience' - type: object properties: member_count: { type: integer } AudienceResponse: type: object required: [success, audience] properties: success: { type: boolean, const: true } audience: { $ref: '#/components/schemas/Audience' } AudienceMember: type: object properties: id: { type: integer } audience_id: { type: integer } email: { type: string, format: email } payload: type: [object, 'null'] additionalProperties: true created_at: { type: string } Template: type: object properties: id: { type: integer } user_id: { type: integer } name: { type: string } subject: { type: string } text_body: { type: [string, 'null'] } html_body: { type: [string, 'null'] } created_at: { type: string } updated_at: { type: string } TemplateResponse: type: object required: [success, template] properties: success: { type: boolean, const: true } template: { $ref: '#/components/schemas/Template' } Error: type: object required: [code, message] description: The standard error body. Branch on `code`; `message` is for humans. properties: code: type: string examples: [RATE_LIMITED] message: { type: string } details: type: object additionalProperties: true description: Extra context, e.g. `retryAfter` (seconds), `limit`, `used`, `upgradeRequired`. SimpleError: type: object required: [error] description: Older error body still returned by authentication and some audience/template checks. properties: error: { type: string } ValidationErrorResponse: type: object required: [success, error] description: The request body failed schema validation. properties: success: { type: boolean, const: false } error: type: object properties: name: { type: string, const: ZodError } issues: type: array items: type: object properties: path: type: array items: { type: [string, integer] } message: { type: string } code: { type: string } responses: BadRequest: description: The body failed validation, or a request-level rule was broken (e.g. `TOO_MANY_RECIPIENTS`, `IDEMPOTENCY_KEY_IN_FLIGHT`). content: application/json: schema: oneOf: - $ref: '#/components/schemas/ValidationErrorResponse' - $ref: '#/components/schemas/Error' examples: validation: summary: Schema validation value: success: false error: name: ZodError issues: - code: invalid_type path: [subject] message: Required tooManyRecipients: summary: Too many recipients value: code: TOO_MANY_RECIPIENTS message: Too many recipients. Maximum 10 allowed for /send. Use /send/batch for 11-1000 recipients. InvalidId: description: The ID in the path is not a number. content: application/json: schema: { $ref: '#/components/schemas/Error' } example: { code: INVALID_ID, message: Invalid batch ID } Unauthorized: description: Missing, malformed, revoked or expired API key. content: application/json: schema: { $ref: '#/components/schemas/SimpleError' } examples: missing: value: { error: Missing or invalid Authorization header } invalid: value: { error: Invalid or expired API token } ConnectionError: description: No usable connection for this request. content: application/json: schema: { $ref: '#/components/schemas/Error' } examples: noConnection: value: { code: NO_CONNECTION, message: No connected account found. Please connect an account first. } unknownFrom: value: { code: CONNECTION_NOT_FOUND, message: No connected account found for someone@else.com. } scopeMismatch: value: { code: CONNECTION_SCOPE_MISMATCH, message: This API key is scoped to you@yourcompany.com and cannot send from other@yourcompany.com. } paused: value: { code: CONNECTION_PAUSED, message: Account you@yourcompany.com is paused. Resume from the dashboard before retrying. } ConnectionOrPlanError: description: No usable connection, or the feature needs the Pro plan (`FEATURE_NOT_AVAILABLE`). content: application/json: schema: { $ref: '#/components/schemas/Error' } examples: proRequired: value: code: FEATURE_NOT_AVAILABLE message: Upgrade to Pro to access this feature. details: { feature: async_sending, upgradeRequired: true } noConnection: value: { code: NO_CONNECTION, message: No connected account found. Please connect an account first. } ProRequired: description: This feature needs the Pro plan. content: application/json: schema: { $ref: '#/components/schemas/Error' } example: code: FEATURE_NOT_AVAILABLE message: Upgrade to Pro to access this feature. details: { feature: audiences, upgradeRequired: true } NotYours: description: The resource belongs to another account. content: application/json: schema: { $ref: '#/components/schemas/SimpleError' } example: { error: Access denied } NotYoursOrProRequired: description: The resource belongs to another account, or the feature needs the Pro plan. content: application/json: schema: oneOf: - $ref: '#/components/schemas/SimpleError' - $ref: '#/components/schemas/Error' examples: notYours: value: { error: Access denied } proRequired: value: code: FEATURE_NOT_AVAILABLE message: Upgrade to Pro to access this feature. details: { feature: audiences, upgradeRequired: true } NotFound: description: No such resource. content: application/json: schema: { $ref: '#/components/schemas/SimpleError' } example: { error: Audience not found } BatchNotFound: description: No batch with this ID on your account. content: application/json: schema: { $ref: '#/components/schemas/Error' } example: { code: BATCH_NOT_FOUND, message: Batch not found } Deleted: description: Deleted. content: application/json: schema: type: object required: [success, message] properties: success: { type: boolean, const: true } message: { type: string } RateLimited: description: | A limit was hit. `details.retryAfter` (seconds) says when to retry, where known. - `DAILY_LIMIT_REACHED` — your plan's daily cap (Free: 100/day). Resets at 00:00 UTC. - `QUOTA_EXCEEDED` — the connection's own daily or hourly quota. `details.resetsAt` says when it resets. - `RATE_LIMITED` — the connection is pacing sends, or your SMTP provider pushed back. content: application/json: schema: { $ref: '#/components/schemas/Error' } examples: rateLimited: value: code: RATE_LIMITED message: Account you@yourcompany.com is pacing sends. Retry in 4s. details: { retryAfter: 4 } dailyLimit: value: code: DAILY_LIMIT_REACHED message: You've sent 100 emails today. Your plan allows 100 per day. Upgrade to Pro for higher limits. details: { limit: 100, used: 100, upgradeRequired: true } quota: value: code: QUOTA_EXCEEDED message: 'Daily quota exceeded for you@yourcompany.com. Limit: 500 emails/day.' details: { limit: 500, used: 500, resetsAt: '2026-09-29T00:00:00.000Z' } SendFailed: description: Your SMTP server rejected the message or could not be reached. `message` carries the server's reason. Nothing was sent and no quota was used. content: application/json: schema: { $ref: '#/components/schemas/Error' } example: code: INTERNAL_ERROR message: '535 5.7.8 Username and Password not accepted' ```