# Attachments (/docs/attachments) # Attachments [#attachments] Send file attachments (PDFs, calendar invites, images, etc.) alongside your emails. ## Overview [#overview] The `attachments` field accepts an array of file objects. Each file must be base64-encoded. No raw MIME construction needed. ### Limits [#limits] | Constraint | Limit | | ------------------------- | --------------- | | Max attachments per email | 10 | | Max total attachment size | 40 MB (decoded) | ## Attachment Object [#attachment-object] | Field | Type | Required | Description | | ------------- | ------ | -------- | --------------------------------------------------------------------------- | | `filename` | string | Yes | The filename shown to the recipient (e.g. `"report.pdf"`). | | `content` | string | Yes | Base64-encoded file content. | | `contentType` | string | No | MIME type. Examples: `"application/pdf"`, `"text/calendar"`, `"image/png"`. | ## Examples [#examples] ### cURL — Calendar Invite [#curl--calendar-invite] ```bash # Base64-encode your file ICS_BASE64=$(base64 -i invite.ics) curl -X POST https://api.envloped.com/v1/emails \ -H "Authorization: Bearer en_abc123..." \ -H "Content-Type: application/json" \ -d "{ \"from\": \"Your App \", \"to\": \"user@example.com\", \"subject\": \"Meeting Invite\", \"html\": \"

See the attached calendar invite.

\", \"attachments\": [{ \"filename\": \"invite.ics\", \"content\": \"$ICS_BASE64\", \"contentType\": \"text/calendar\" }] }" ``` ### JavaScript / TypeScript (SDK) [#javascript--typescript-sdk] ```typescript import { EnvlopedClient } from '@envloped/envloped-js'; import { readFileSync } from 'fs'; const client = new EnvlopedClient({ apiKey: 'en_abc123...' }); const pdfBase64 = readFileSync('report.pdf').toString('base64'); await client.emails.send({ from: 'Your App ', to: ['user@example.com'], subject: 'Monthly Report', html: '

Please find the report attached.

', attachments: [ { filename: 'report.pdf', content: pdfBase64, contentType: 'application/pdf', }, ], }); ``` ### Go (SDK) [#go-sdk] ```go import ( "encoding/base64" "os" envloped "github.com/envloped/envloped-go" ) client := envloped.NewClient("en_abc123...") data, _ := os.ReadFile("invite.ics") encoded := base64.StdEncoding.EncodeToString(data) resp, err := client.Emails.Send(&envloped.SendEmailRequest{ From: "Your App ", To: []string{"user@example.com"}, Subject: "Meeting Invite", Html: "

See the attached calendar invite.

", Attachments: []envloped.Attachment{ { Filename: "invite.ics", Content: encoded, ContentType: "text/calendar", }, }, }) ``` ### Python [#python] ```python import base64 import os import requests with open("report.pdf", "rb") as f: pdf_base64 = base64.b64encode(f.read()).decode("utf-8") response = requests.post( "https://api.envloped.com/v1/emails", headers={ "Authorization": f"Bearer {os.environ['ENVLOPED_API_KEY']}", "Content-Type": "application/json", }, json={ "from": "Your App ", "to": "user@example.com", "subject": "Monthly Report", "html": "

Please find the report attached.

", "attachments": [ { "filename": "report.pdf", "content": pdf_base64, "contentType": "application/pdf", } ], }, ) ``` ### Multiple Attachments [#multiple-attachments] ```json { "from": "hello@yourdomain.com", "to": "user@example.com", "subject": "Your order confirmation", "html": "

Receipt and calendar invite attached.

", "attachments": [ { "filename": "receipt.pdf", "content": "JVBERi0xLjQ...", "contentType": "application/pdf" }, { "filename": "delivery.ics", "content": "QkVHSU46VkNB...", "contentType": "text/calendar" } ] } ``` ## Error Responses [#error-responses] ### Missing filename [#missing-filename] ```json { "error": "Each attachment must have a filename" } ``` ### Missing content [#missing-content] ```json { "error": "Each attachment must have base64-encoded content" } ``` ### Too many attachments [#too-many-attachments] ```json { "error": "Maximum 10 attachments allowed" } ``` ### Total size exceeded [#total-size-exceeded] ```json { "error": "Total attachment size must not exceed 40MB" } ``` # Authentication (/docs/authentication) # Authentication [#authentication] All Envloped API requests require authentication using an API key. This guide shows you how to get your API key and use it in your requests. ## Getting Your API Key [#getting-your-api-key] 1. Log in to your [Envloped dashboard](https://envloped.com) 2. Navigate to **Settings** → **API Keys** 3. Click **Create API Key** 4. Give your key a descriptive name (e.g., "Production Server") 5. Copy the key immediately - it won't be shown again ## Using Your API Key [#using-your-api-key] Include your API key in the `Authorization` header of every request: ``` Authorization: Bearer YOUR_API_KEY ``` ### cURL Example [#curl-example] ```bash curl -X POST https://api.envloped.com/v1/emails \ -H "Authorization: Bearer en_abc123..." \ -H "Content-Type: application/json" \ -d '{"to": "user@example.com", "subject": "Hello"}' ``` ### JavaScript Example [#javascript-example] ```javascript const response = await fetch('https://api.envloped.com/v1/emails', { method: 'POST', headers: { 'Authorization': 'Bearer en_abc123...', 'Content-Type': 'application/json', }, body: JSON.stringify({ from: 'Your App ', to: 'user@example.com', subject: 'Hello from Envloped', html: '

Welcome!

', }), }); const data = await response.json(); ``` ### Python Example [#python-example] ```python import requests response = requests.post( 'https://api.envloped.com/v1/emails', headers={ 'Authorization': 'Bearer en_abc123...', 'Content-Type': 'application/json', }, json={ 'from': 'Your App ', 'to': 'user@example.com', 'subject': 'Hello from Envloped', 'html': '

Welcome!

', } ) print(response.json()) ``` ## Security Best Practices [#security-best-practices] * **Never expose your API key** in client-side code or public repositories * **Use environment variables** to store your API key * **Rotate keys regularly**, especially if you suspect they may have been compromised * **Use separate keys** for development and production environments * **Delete unused keys** from your dashboard ## Environment Variables [#environment-variables] We recommend storing your API key in an environment variable: ```bash # .env file (never commit this!) ENVLOPED_API_KEY=en_abc123... ``` ```javascript // Access in your code const apiKey = process.env.ENVLOPED_API_KEY; ``` ## Error Responses [#error-responses] If authentication fails, you'll receive a `401 Unauthorized` response: ```json { "error": { "code": "unauthorized", "message": "Invalid or missing API key" } } ``` Common causes: * Missing `Authorization` header * Invalid or revoked API key * Incorrect header format (must be `Bearer YOUR_KEY`) # Campaigns (/docs/campaigns) # Campaigns [#campaigns] A campaign is a marketing email plus an **audience**. The audience is everyone in the campaign's include sources, minus everyone in its exclude sources, resolved at the moment the send starts. All endpoints require an `Authorization: Bearer YOUR_API_KEY` header. ## The campaign object [#the-campaign-object] ```json { "id": "cp_abc123", "name": "August Singapore edition", "from_address": "Never Strangers ", "reply_to": null, "subject": "Saturday in Singapore", "status": "draft", "scheduled_at": null, "targeted": 0, "sent_count": 0, "skipped_count": 0, "audience": { "include": [{ "source_type": "segment", "source_id": "sg_abc123" }], "exclude": [{ "source_type": "campaign", "source_id": "cp_lastweek" }] }, "list_ids": [], "created_at": "2026-08-09T10:30:00Z", "updated_at": "2026-08-09T10:30:00Z" } ``` `status` moves through `draft` → `scheduled` / `queued` → `sending` → `sent`, with `paused`, `canceled` and `failed` as terminal detours. ## Audience [#audience] ``` GET /v1/campaigns/:id/audience PUT /v1/campaigns/:id/audience ``` An audience is two lists of sources: ```json { "include": [ { "source_type": "segment", "source_id": "sg_singapore_25_35" }, { "source_type": "list", "source_id": "ls_vip" } ], "exclude": [ { "source_type": "segment", "source_id": "sg_already_has_ticket" }, { "source_type": "campaign", "source_id": "cp_three_days_ago" } ] } ``` | `source_type` | In include mode | In exclude mode | | ------------- | --------------------------------------------------------- | ------------------------------------------------- | | `list` | Everyone on the list who has not opted out of it | …and remove them | | `segment` | Everyone matching the [segment](/docs/segments) right now | …and remove them | | `campaign` | *(not typical)* | Everyone a past campaign was **actually sent** to | Exclusion is not the inverse of a filter — the two sides are built from different criteria. "Send to Manila, but not to anyone we emailed three days ago" is one include and one exclude, and the exclude is a campaign. `PUT` replaces the audience wholesale and returns what was stored. Sources that do not exist, are deleted, or belong to another account are dropped and counted in `dropped_sources`. ```bash curl -X PUT https://api.envloped.com/v1/campaigns/cp_abc123/audience \ -H "Authorization: Bearer en_abc123..." \ -H "Content-Type: application/json" \ -d '{ "audience": { "include": [{ "source_type": "segment", "source_id": "sg_singapore_25_35" }], "exclude": [{ "source_type": "campaign", "source_id": "cp_three_days_ago" }] } }' ``` > **`list_ids` is deprecated.** It still works on campaign create and update and > is treated as include-list sources, but it cannot express segments or > exclusions. Prefer `audience`. When both are sent, `audience` wins. ## Preview the audience before sending [#preview-the-audience-before-sending] ``` GET /v1/campaigns/:id/audience/preview?limit=25 ``` This is the number to show a human before a send, and the sample to let them sanity-check it. ```json { "included": 4210, "excluded": 388, "eligible": 3822, "breakdown": { "ineligible": 0, "unsubscribed": 41, "suppressed": 12, "bounced": 3, "complained": 0 }, "sample": [ { "id": "ct_1", "email": "jane@example.com", "first_name": "Jane", "last_name": null, "status": "subscribed", "attributes": { "city": "Singapore", "date_of_birth": "1996-04-12" } } ] } ``` | Field | Meaning | | ----------- | -------------------------------------------------------------- | | `included` | Size of the union of include sources. | | `excluded` | How many of those the exclude sources removed. | | `eligible` | **The headline number: how many emails will actually go out.** | | `breakdown` | Why the rest will not be mailed. | | `sample` | Up to 100 real contacts from the eligible set. | The identity `included − excluded = eligible + sum(breakdown)` always holds, so every contact the audience pulled in is accounted for. This endpoint runs the same resolution the send does, so the preview cannot disagree with the send. ## The three gates [#the-three-gates] Before an address is mailed it must clear all three. A segment cannot override any of them. | Gate | Means | | -------------- | -------------------------------------------------------------------------------------------- | | Subscription | The contact has not opted out (`status`). | | Deliverability | Mail to them has not bounced, complained, or been suppressed. | | Eligibility | The contact is allowed to be mailed at all (`eligibility`) — see [Contacts](/docs/contacts). | Contacts blocked by a gate are still recorded against the campaign as `skipped` with a reason, so the report's funnel stays honest. ## Send or schedule [#send-or-schedule] ``` POST /v1/campaigns/:id/send ``` | Parameter | Type | Description | | -------------- | ------ | ------------------------------------------------------------ | | `scheduled_at` | string | ISO 8601, at least a minute in the future. Omit to send now. | **A scheduled campaign resolves its audience when it fires, not when it was scheduled.** A campaign scheduled on Friday for Monday 10am sends to whoever matches on Monday at 10am. ```bash curl -X POST https://api.envloped.com/v1/campaigns/cp_abc123/send \ -H "Authorization: Bearer en_abc123..." \ -H "Content-Type: application/json" \ -d '{ "scheduled_at": "2026-08-11T02:00:00Z" }' ``` ### From the CLI [#from-the-cli] ```bash envloped campaigns send cp_abc123 envloped campaigns send cp_abc123 --at 2026-08-11T02:00:00Z ``` `send` is the one CLI verb that reaches thousands of inboxes and cannot be undone, so it confirms first — printing the campaign, its subject, and the eligible recipient count from the audience preview before asking. `--yes` skips the prompt, and it is **skipped automatically when stdout is not a terminal**, so CI is unaffected. When a send is refused with `empty_audience`, the CLI renders the audience snapshot the error carries — included, excluded, eligible, and the per-reason drop breakdown — rather than only the message. ### Send errors [#send-errors] | Status | `code` | Meaning | | ------ | ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------- | | `400` | `no_include_source` | The audience has no include source. Excludes alone say who *not* to mail and nothing about who to mail. | | `400` | `empty_audience` | The audience resolves to zero sendable recipients. The response carries the full preview so you can see why. Nothing is written. | | `422` | `unknown_field` | A segment in the audience references a field that has since been archived. | | `422` | `invalid_segment` | A segment in the audience is no longer valid. | | `422` | `dead_source` | A segment in the audience has been deleted. | | `403` | `domain_not_verified` | The `from_address` domain is not verified for this account. | | `403` | `marketing_profile_incomplete` | Your business address is required before any marketing send. | | `403` | `reputation_suspended` | Sending is suspended for this account. | | `422` | `missing_unsubscribe` | The message has no unsubscribe link even after footer injection. | A `dead_source` or `unknown_field` is deliberately a hard failure rather than a silent drop: dropping an include under-sends and dropping an exclude over-sends, and neither is ours to guess. ## Other endpoints [#other-endpoints] | Method | Path | Purpose | | -------- | ------------------- | ----------------------------------------------------- | | `GET` | `/v1/campaigns` | Paginated list with live recipient counts. | | `POST` | `/v1/campaigns` | Create a draft. Accepts `audience` and `template_id`. | | `GET` | `/v1/campaigns/:id` | Detail, including `audience` and counts. | | `PATCH` | `/v1/campaigns/:id` | Edit a `draft` or `scheduled` campaign. | | `DELETE` | `/v1/campaigns/:id` | Delete a non-active campaign. | Their CLI equivalents: ```bash envloped campaigns list envloped campaigns get cp_abc123 envloped campaigns create "August newsletter" --from "You " \ --subject "What shipped in August" --html-file ./newsletter.html --include sg_abc123 envloped campaigns update cp_abc123 --subject "What shipped in August ☀️" envloped campaigns audience cp_abc123 # show envloped campaigns audience cp_abc123 --include sg_abc123 --exclude sg_churned envloped campaigns preview cp_abc123 # eligible count + sample envloped campaigns test cp_abc123 --to you@yourdomain.com envloped campaigns cancel cp_abc123 envloped campaigns report cp_abc123 ``` `campaigns report` prints every block by default — funnel, opens, clicks per link, the hourly series, and recent activity. Narrow it with `--section` (repeatable): `--section summary --section links`. The headline open figures are the **proxy-excluded** ones; the raw rate is shown once, labelled, because Apple Mail Privacy Protection prefetches images and inflates it. ## Merge tags [#merge-tags] Subject and body support `{{ key }}`, resolving against the contact's `first_name`, `last_name`, `email`, and every registered [contact field](/docs/contacts/contact-fields). Unknown tags render as empty rather than leaking the raw tag to a recipient. Every campaign automatically gets a compliant footer with your business address and a one-click unsubscribe link, plus RFC 8058 `List-Unsubscribe` headers. # Domain Verification (/docs/domain-verification) # Domain Verification [#domain-verification] Before you can send emails, you need to verify ownership of your sending domain. This improves deliverability and ensures your emails don't get marked as spam. ## Why Verify Your Domain? [#why-verify-your-domain] * **Better deliverability** - Verified domains have higher inbox placement rates * **Brand protection** - Prevents others from spoofing your domain * **Required for sending** - You can only send from verified domains ## Adding a Domain [#adding-a-domain] 1. Log in to your [Envloped dashboard](https://envloped.com) 2. Navigate to **Settings** → **Domains** 3. Click **Add Domain** 4. Enter your domain (e.g., `yourdomain.com`) 5. Click **Continue** to get your DNS records ## DNS Configuration [#dns-configuration] After adding your domain, Envloped shows the exact DNS records to create. They both verify ownership and set up DKIM signing — one set of records does both jobs. ### Required records (DKIM) [#required-records-dkim] You will be given **three CNAME records**. Each one carries a unique token, so copy them from your dashboard rather than from this page: | Type | Name | Value | | ----- | ------------------------------------ | ---------------------------- | | CNAME | `._domainkey.yourdomain.com` | `.dkim.envloped.com` | | CNAME | `._domainkey.yourdomain.com` | `.dkim.envloped.com` | | CNAME | `._domainkey.yourdomain.com` | `.dkim.envloped.com` | Each `` is a long random string unique to your domain. All three are required — DKIM key rotation uses them in turn, and a domain with only some of them published will start failing authentication later, not immediately. Nothing else is needed to start sending: ownership verification rides on the same records. ### What DKIM does, and why it is not optional [#what-dkim-does-and-why-it-is-not-optional] DKIM attaches a cryptographic signature to every message, which the receiving mail server checks against the public key published in these DNS records. Mail that fails the check — or has no signature at all — is far more likely to be filed as spam, and Gmail and Yahoo now **require** authentication for bulk senders. Envloped will not let you send from an unverified domain for exactly this reason. Two things to check on your DNS provider: * **Do not proxy these records.** On Cloudflare they must be "DNS only" (grey cloud). A proxied CNAME breaks the lookup. * **Watch for an auto-appended domain.** Many providers append your domain to whatever you type in the *name* field. If yours does, enter `._domainkey` rather than the full hostname, or you will end up with `._domainkey.yourdomain.com.yourdomain.com`. This is the single most common reason verification never completes. ### Optional but recommended: DMARC [#optional-but-recommended-dmarc] Once DKIM is verified, publish a DMARC record so receivers know what to do with mail that fails authentication: | Type | Name | Value | | ---- | ----------------------- | --------------------------------------------------- | | TXT | `_dmarc.yourdomain.com` | `v=DMARC1; p=none; rua=mailto:dmarc@yourdomain.com` | Start at `p=none` (monitor only), read the reports for a couple of weeks, and tighten to `p=quarantine` once you are confident every legitimate sender for the domain is authenticated. ### Adding DNS Records [#adding-dns-records] The exact steps depend on your DNS provider. Here are instructions for common providers: **Cloudflare:** 1. Go to your domain's DNS settings 2. Click **Add record** 3. Select **CNAME** as the type 4. Enter the name and target from the table above 5. Set Proxy status to **DNS only** (gray cloud) 6. Click **Save** **GoDaddy:** 1. Go to **My Products** → **DNS** 2. Click **Add** under Records 3. Select **CNAME** as the type 4. Enter the host and points to values 5. Click **Save** **Namecheap:** 1. Go to **Domain List** → **Manage** 2. Click **Advanced DNS** 3. Click **Add New Record** 4. Select **CNAME Record** 5. Enter the host and target values 6. Click the checkmark to save ## Verification Process [#verification-process] After adding the DNS records: 1. Return to your Envloped dashboard 2. Go to **Settings** → **Domains** 3. Click **Verify** next to your domain 4. Wait for verification (usually 1-5 minutes, can take up to 48 hours) The status will change from **Pending** to **Verified** once complete. ## Troubleshooting [#troubleshooting] ### Verification taking too long? [#verification-taking-too-long] DNS propagation can take up to 48 hours. If verification is still pending after this time: * Double-check the DNS records are entered correctly * Ensure there are no typos in the record names or values * Check if your DNS provider requires you to omit the domain suffix * Try using a DNS lookup tool to verify the records are visible ### Common Issues [#common-issues] **"Record not found" error:** * The DNS record may not have propagated yet * The record name might be incorrect - some providers automatically append the domain **"Invalid record value" error:** * Check for extra spaces or characters in the value * Ensure you copied the full value from the dashboard **Domain stuck in "Pending" status:** * Clear your DNS cache and try again * Contact support if the issue persists after 48 hours ## Sending From Subdomains [#sending-from-subdomains] You can send from any subdomain of a verified root domain. For example, if you verify `yourdomain.com`, you can send from: * `hello@yourdomain.com` * `noreply@mail.yourdomain.com` * `updates@notifications.yourdomain.com` # Idempotency (/docs/idempotency) # Idempotency [#idempotency] Networks fail in the worst possible place: after the server did the work but before you saw the response. Retrying blindly sends the email twice. Send an `Idempotency-Key` header and Envloped remembers the outcome of the request, so a retry with the same key returns the original response instead of performing the operation again. The header is **optional**. Requests without it behave exactly as they always have — nothing is recorded and nothing is checked. ## Supported endpoints [#supported-endpoints] | Endpoint | What a replay returns | | ------------------------- | ----------------------------------------------------------------------------------------------------------------- | | `POST /v1/emails` | The original send response, including the same `id` and `messageId`. No second email is sent. | | `POST /v1/contacts/batch` | The original receipt: the same `created` / `updated` / `rejected_ineligible` counts and the same per-row results. | Keys are scoped per endpoint and per account, so the same key used on `/v1/emails` and `/v1/contacts/batch` refers to two independent operations, and another account's key can never collide with yours. ## Choosing a key [#choosing-a-key] The key must identify **one logical operation**, not one HTTP attempt. Generate it once, before the first attempt, and reuse it for every retry of that same operation. ```typescript // Good: the key is derived from the thing you are doing. const key = `booking-${bookingId}-confirmation` // Also good: a UUID generated once, then reused across retries. const key = crypto.randomUUID() for (let attempt = 0; attempt < 3; attempt++) { const res = await fetch('https://api.envloped.com/v1/emails', { method: 'POST', headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json', 'Idempotency-Key': key, // ← same key on every attempt }, body: JSON.stringify(payload), }) if (res.ok) break } ``` A key generated *inside* the retry loop is fresh on every attempt and dedupes nothing. Keys may be up to 255 characters. Anything unique is fine — a UUID, or an id from your own database, which has the advantage of surviving your process crashing and restarting. ## Responses [#responses] | Status | When | What to do | | -------------- | ------------------------------------------------------------------------- | --------------------------------------------------------------------------- | | `2xx` | First time this key was seen. | Normal path. | | `2xx` (replay) | The key completed earlier. The stored response body is returned verbatim. | Nothing — the operation already happened exactly once. | | `409` | The key is currently in flight (a first attempt has not finished). | Wait and retry; do not treat as failure. | | `422` | The key was used before **with a different request body**. | A bug on your side: the same key is being reused for a different operation. | The `422` is deliberate. Reusing one key for two different payloads is ambiguous — Envloped refuses rather than guessing which one you meant. ## Sending a batch you can reconcile [#sending-a-batch-you-can-reconcile] For a large contact backfill split across many requests, derive the key from the batch number: ```bash curl -X POST https://api.envloped.com/v1/contacts/batch \ -H "Authorization: Bearer en_abc123..." \ -H "Content-Type: application/json" \ -H "Idempotency-Key: members-backfill-2026-08-09-batch-17" \ -d '{ "contacts": [ /* up to 1000 */ ] }' ``` If a batch's response never arrives, replay the same request with the same key. Either it runs for the first time, or you get the receipt for the run that already happened. Both outcomes are correct, and you can tell them apart from the counts. ## What this does not cover [#what-this-does-not-cover] Idempotency protects against **duplicate delivery of the same operation**. It does not deduplicate two genuinely different requests that happen to look alike: sending the same email body to the same recipient twice with two different keys sends two emails, by design. # Getting Started (/docs) # Getting Started with Envloped [#getting-started-with-envloped] Envloped is an AI-powered email platform for developers. Our API makes it simple to send transactional emails from your applications. ## Base URL [#base-url] All API requests should be made to: ``` https://api.envloped.com/v1 ``` ## Quick Start [#quick-start] 1. **Create an account** at [envloped.com](https://envloped.com) 2. **Get your API key** from the dashboard 3. **Verify your domain** to enable sending 4. **Send your first email** using our API ## Example Request [#example-request] ```bash curl -X POST https://api.envloped.com/v1/emails \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "from": "Your App ", "to": "recipient@example.com", "subject": "Hello from Envloped", "html": "

Your first email!

" }' ``` ## Next Steps [#next-steps] * [Authentication](/docs/authentication) - Learn how to authenticate your requests * [Domain Verification](/docs/domain-verification) - Set up your sending domain * [Send Email API](/docs/send-email) - Full API reference for sending emails * [Attachments](/docs/attachments) - Send file attachments with your emails # Lists (/docs/lists) # Lists [#lists] Lists group [Contacts](/docs/contacts) for campaign targeting. A contact can belong to any number of lists, and each membership tracks its own per-list opt-out independently of the contact's global status. All endpoints require an `Authorization: Bearer YOUR_API_KEY` header. ## The list object [#the-list-object] ```json { "id": "ls_newsletter", "name": "Newsletter", "description": "Weekly product digest", "member_count": 1280, "created_at": "2026-01-15T10:30:00Z" } ``` | Field | Type | Description | | -------------- | -------------- | ------------------------------------------------ | | `id` | string | Unique list identifier. | | `name` | string | List name (unique per account among live lists). | | `description` | string \| null | Optional description. | | `member_count` | number | Count of subscribed (non-opted-out) members. | | `created_at` | string | ISO 8601 creation timestamp. | ## List all lists [#list-all-lists] ``` GET /v1/lists ``` Returns every live list with its member count. ```json { "lists": [ /* list objects */ ] } ``` ```bash curl "https://api.envloped.com/v1/lists" \ -H "Authorization: Bearer en_abc123..." ``` ## Create a list [#create-a-list] ``` POST /v1/lists ``` ### Request body [#request-body] | Parameter | Type | Required | Description | | ------------- | ------ | -------- | ------------------------------- | | `name` | string | Yes | List name (unique per account). | | `description` | string | No | Optional description. | ### Response — `201 Created` [#response--201-created] ```json { "list": { /* list object */ } } ``` ```bash curl -X POST https://api.envloped.com/v1/lists \ -H "Authorization: Bearer en_abc123..." \ -H "Content-Type: application/json" \ -d '{ "name": "Newsletter", "description": "Weekly product digest" }' ``` ### Errors [#errors] | Status | `error` | Meaning | | ------ | ------------------ | ------------------------------------------ | | `400` | `name is required` | The `name` field was missing or empty. | | `409` | `duplicate_list` | A live list with this name already exists. | ## Retrieve a list [#retrieve-a-list] ``` GET /v1/lists/:id ``` Returns a single [list object](#the-list-object) with its current `member_count` (the number of subscribed, non-opted-out contacts). ```json { "list": { "id": "ls_newsletter", "name": "Newsletter", "description": "Weekly product digest", "member_count": 1280, "created_at": "2026-01-15T10:30:00Z" } } ``` Returns `404` with `{ "error": "List not found" }` if the id is unknown or the list has been soft-deleted. To list the contacts on a list, use [`GET /v1/lists/:id/contacts`](#list-members). ```bash curl "https://api.envloped.com/v1/lists/ls_newsletter" \ -H "Authorization: Bearer en_abc123..." ``` ## Update a list [#update-a-list] ``` PATCH /v1/lists/:id ``` Rename or re-describe a list. Only supplied fields change. | Parameter | Type | Description | | ------------- | -------------- | ----------------------------------- | | `name` | string | New name (must stay unique). | | `description` | string \| null | New description (`null` clears it). | ```bash curl -X PATCH https://api.envloped.com/v1/lists/ls_newsletter \ -H "Authorization: Bearer en_abc123..." \ -H "Content-Type: application/json" \ -d '{ "name": "Weekly Newsletter" }' ``` A name collision returns `409` with `{ "error": "duplicate_list" }`. ## Delete a list [#delete-a-list] ``` DELETE /v1/lists/:id ``` **Soft delete.** The list is marked deleted and stops applying to campaign targeting, but the contacts themselves are untouched — they remain in your audience and on any other lists. ```json { "deleted": true, "id": "ls_newsletter" } ``` ## Membership [#membership] ### List members [#list-members] ``` GET /v1/lists/:id/contacts ``` Returns a paginated page of the list's members. Each contact carries two extra membership fields: `joined_at` and `list_unsubscribed_at` (the per-list opt-out timestamp, or `null`). | Query parameter | Type | Description | | --------------- | ------ | ---------------------------------- | | `page` | number | 1-based page number (default `1`). | | `limit` | number | Page size, 1–100 (default `50`). | ```json { "contacts": [ { "id": "ct_abc123", "email": "jane@example.com", "status": "subscribed", "joined_at": "2026-01-15T10:30:00Z", "list_unsubscribed_at": null } ], "total": 1280, "page": 1, "totalPages": 26 } ``` ### Add members [#add-members] ``` POST /v1/lists/:id/contacts ``` Adds one or more **existing** contacts to the list. Send either `contact_id` (single) or `contact_ids` (array, max 1000). Only contacts owned by your account are eligible; unknown ids are returned in `invalid`. Adding a contact that is already a member is a no-op and does not clear a prior per-list opt-out. ```json { "contact_ids": ["ct_abc123", "ct_def456"] } ``` Response: ```json { "added": ["ct_abc123", "ct_def456"], "invalid": [] } ``` ### Remove members [#remove-members] ``` DELETE /v1/lists/:id/contacts ``` Removes membership rows (the contacts themselves are not deleted). Send `contact_id` or `contact_ids` in the request body. ```json { "removed": ["ct_def456"] } ``` ## SDKs & CLI [#sdks--cli] ```typescript // JavaScript / TypeScript const list = await client.lists.create({ name: 'Newsletter' }); await client.lists.addContacts(list.id, ['ct_abc123']); ``` ```go // Go list, _ := client.Lists.Create(&envloped.CreateListRequest{Name: "Newsletter"}) client.Lists.AddContacts(list.List.ID, []string{"ct_abc123"}) ``` ```bash # CLI envloped lists create "Newsletter" envloped lists add ls_newsletter ct_abc123 ct_def456 envloped lists members ls_newsletter ``` # MCP Server (/docs/mcp) # MCP Server [#mcp-server] Envloped ships a native [Model Context Protocol](https://modelcontextprotocol.io) server inside the CLI binary. Point Claude Desktop, Claude Code, Cursor, or any MCP client at it and the agent can send transactional email, debug domain verification, build contact segments, draft campaigns, and read campaign reports — using your existing Envloped account. There is **no separate package to install**. If you have the CLI, you have the MCP server. ```bash envloped mcp ``` ## Two ways to connect [#two-ways-to-connect] | | **Local (stdio)** | **Hosted (Streamable HTTP)** | | --------------------- | ----------------------------------------------------------------------- | ----------------------------------------------------- | | Endpoint | `envloped mcp` on your machine | `https://mcp.envloped.com/mcp` | | Authentication | The API key `envloped login` already stored | OAuth 2.1, with a consent screen | | Setup | One config block | Paste a URL, approve in the browser, pick a workspace | | Which workspace | Fixed by the API key — a key belongs to the workspace it was created in | Chosen on the consent screen, once per authorization | | Effective permissions | **Everything an API key can do**, including send | Only the scopes approved on the consent screen | | Best for | Local development, coding agents, CI | Claude Desktop, claude.ai, third-party agents | That last row is the asymmetry worth understanding before you pick one. An API key is unscoped and can do everything the API can do, including send — so a local config hands an agent a full-access credential. An OAuth token carries only what you approved on the consent screen, and sending is a separate, default-off toggle there. *** ## Local setup (stdio) [#local-setup-stdio] ### 1. Install and authenticate the CLI [#1-install-and-authenticate-the-cli] ```bash curl -fsSL https://envloped.com/install.sh | bash envloped login ``` The key is stored in `~/.config/envloped/credentials.toml` (or `$XDG_CONFIG_HOME/envloped/`). The MCP server reads it automatically — you do not put your key in the MCP config file. ### 2. Add it to your client [#2-add-it-to-your-client] #### Claude Code [#claude-code] ```bash claude mcp add envloped -- envloped mcp ``` Verify with `/mcp` inside Claude Code. The server should report as connected with 20 tools. #### Claude Desktop [#claude-desktop] Claude Desktop connects to the **hosted** server instead — it takes a URL in **Settings → Connectors**, not a stdio command. See [Hosted setup](#hosted-setup-oauth). #### Cursor [#cursor] Create `.cursor/mcp.json` in your project (or `~/.cursor/mcp.json` for every project): ```json { "mcpServers": { "envloped": { "command": "envloped", "args": ["mcp"] } } } ``` #### Other clients [#other-clients] Any client that speaks stdio works. The command is `envloped` and the single argument is `mcp`. If your client cannot inherit your shell environment, or you want to pin a specific account, pass the key explicitly instead of relying on `envloped login`: ```json { "mcpServers": { "envloped": { "command": "envloped", "args": ["mcp"], "env": { "ENVLOPED_API_KEY": "en_..." } } } } ``` ### 3. Try it [#3-try-it] > Which of my domains are verified, and what DNS records are still missing? The agent calls `domain_list`, then `domain_records` for anything unverified, and reports the live SES status per record. *** ## Hosted setup (OAuth) [#hosted-setup-oauth] The hosted server needs no install at all — just the URL: ``` https://mcp.envloped.com/mcp ``` ### Claude Desktop and claude.ai [#claude-desktop-and-claudeai] 1. Open **Settings → Connectors**. 2. Click **Add custom connector**. 3. Name it `Envloped` and paste `https://mcp.envloped.com/mcp` as the remote MCP server URL. Leave the advanced OAuth fields empty — the server registers the client for you. 4. Click **Add**, then **Connect**. On Team and Enterprise plans an owner adds the connector once under **Organization settings → Connectors** (**Add → Custom → Web**); everyone else then finds it under **Settings → Connectors** and only has to press **Connect**. Connecting opens Envloped's consent screen in your browser. Once approved, enable Envloped per conversation from the **+** button → **Connectors**. ### Claude Code [#claude-code-1] ```bash claude mcp add --transport http envloped https://mcp.envloped.com/mcp ``` Run `/mcp`, select **envloped**, and authenticate. The same consent screen opens in your browser. ### Cursor and other clients [#cursor-and-other-clients] Any client that accepts a remote MCP URL takes the endpoint directly: ```json { "mcpServers": { "envloped": { "url": "https://mcp.envloped.com/mcp" } } } ``` ### What you are approving [#what-you-are-approving] The consent screen asks three things: whether to authorize the client, **which workspace to connect it to**, and whether the agent may send email. Approve, and the client holds an OAuth 2.1 access token bound to your user, that workspace, and exactly the permissions shown on the page. The workspace choice is **per authorization, not per session**. It is fixed for the life of that client's token — the agent cannot switch workspaces mid-conversation, and it has no visibility into the workspaces you did not pick. To point a client at a different workspace, authorize it again and choose the other one. Every authorization is listed under **Settings → Connected agents** with the permissions it holds and when it was last used, and revoking one there takes effect on the agent's very next request. Tokens expire on their own after 90 days; re-authorizing is the same flow again. Ask the agent to call `whoami` immediately after connecting. It reports the workspace the token actually resolved to, which is the one thing worth confirming before an agent starts acting on your account. The hosted server never holds a credential of its own. Every request is executed with *your* token, which is why one tenant's agent cannot reach another tenant's data even in principle. Once connected, your client's own tool list shows everything the server exposes — twenty tools across email, domains, contacts, segments, and campaigns. You do not need to configure or learn them: each carries its own schema, and the server sends the agent its operating instructions when the connection opens. Notably absent, and deliberately so: there is no bulk campaign send, no API key creation, and no delete. An agent drafts, targets, previews and tests; a human presses Launch. *** ## See also [#see-also] * [CLI commands](/docs/cli/commands) — the same operations at the terminal, plus the ones withheld from agents * [Segments](/docs/segments) — the full definition grammar * [Campaigns](/docs/campaigns) — the send pipeline an agent drafts into * [Authentication](/docs/authentication) — API keys and OAuth # Quotas (/docs/quotas) # Quotas [#quotas] Envloped is **prepaid**. You can never consume email volume you have not already paid for, which means two things in practice: * There is no invoice at the end of the month for volume you did not expect. * When the balance runs out, the API **refuses the send** with `402` rather than quietly adding to a bill. The second is the part worth designing for, and the rest of this page is about doing that well. Prices are on the [pricing page](/pricing); this page covers only the limits. ## What you get [#what-you-get] | | Legacy free | Transactional | Marketing | | ----------- | ---------------------------------------------- | ------------------------------------- | ------------------------------------------------------------------- | | Included | **100 emails for the lifetime of the account** | 20,000 emails per billing period | 5,000 active contacts, and 75,000 campaign sends per billing period | | Beyond that | nothing — subscribe to continue | prepaid email packs, or auto-recharge | a higher contact ceiling, raised automatically | **The free column is not something you can sign up for.** Creating an account takes a payment method and starts a subscription, so every new account arrives on Transactional, Marketing, or both. The free allowance is the state that **legacy and lapsed accounts** are in: accounts made before signup began taking a card, and subscriptions that were cancelled or went unpaid past day 14. For those accounts the allowance is **not monthly**. It is 100 emails for the lifetime of the account, and it does not reset. This is the single most common misunderstanding of this page, so the API says it in the refusal too. Transactional and Marketing are independent. Hold either, or both — held together they share one balance, one contact store, and one sending reputation. The campaign-send allowance scales with the contact ceiling: 15 sends per licensed contact, so raising the ceiling raises it too. An **active contact** is one who is subscribed, or who opened, clicked or received a campaign in the last 30 days. Unsubscribed, bounced and dormant contacts are free to store. ## One unit, counted once: recipients [#one-unit-counted-once-recipients] Quota is measured in **recipients**, not requests and not API calls. ```json { "to": ["a@example.com", "b@example.com", "c@example.com"], ... } ``` That request costs **3**, exactly as three single-recipient requests would. Transactional sends, campaign sends and campaign *test* sends all draw on the same balance. The balance is spent in a fixed order: included quota first, then prepaid credits, oldest pack first. Credits roll over for as long as the subscription is active. ## When a send is refused [#when-a-send-is-refused] Every send path answers with `402` and a machine-readable `code`: ```json { "error": "Your prepaid email balance is exhausted. Top up to keep sending.", "code": "quota_exhausted", "balance": { "includedRemaining": 0, "creditBalance": 0, "freeRemaining": 0 }, "top_up_url": "https://envloped.com/settings/billing" } ``` **Branch on `code`.** The message is prose and may be reworded; the codes are API surface and are added to, never renamed. | Code | What happened | What fixes it | | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | | `free_quota_exhausted` | The 100 lifetime free emails are used. Only a legacy or lapsed account can receive this — a subscribed account never sees it | Subscribe, or fix the lapsed subscription. Waiting does not help: the allowance does not reset | | `quota_exhausted` | Included volume and prepaid credits are both at zero | Buy a pack, or turn on auto-recharge | | `payment_required` | An invoice went unpaid past its grace period | Update the payment method. **Topping up does not lift this** | | `account_blocked` | The account is suspended or closed | Contact [support@envloped.com](mailto:support@envloped.com) | A campaign launch that cannot be afforded is refused *before* any recipient row is written, and its `402` carries an extra `shortfall` — how many more recipients the account would need. A campaign already draining that runs out pauses at `paused_billing` and resumes when the balance does; nothing is double-sent and nothing is lost. ### Never retry a 402 [#never-retry-a-402] A `429` clears by waiting. A `402` clears only when money moves — retrying the same request will fail identically until it does. If your client has one retry policy for "server said no", make sure `402` is not in it. Both SDKs and the CLI enforce this — the CLI in particular never retries a `402`, where it does retry `429` and `5xx`. ```ts import { isPaymentRequiredError } from '@envloped/envloped-js'; try { await envloped.emails.send({ from, to, subject, html }); } catch (error) { if (isPaymentRequiredError(error)) { switch (error.code) { case 'free_quota_exhausted': return promptToSubscribe(error.topUpUrl); case 'quota_exhausted': return promptToTopUp(error.balance); default: return alertAnOperator(error); // a card or lifecycle problem } } throw error; } ``` ```go _, err := client.Emails.Send(req) if errors.Is(err, envloped.ErrPaymentRequired) { var pre *envloped.PaymentRequiredError errors.As(err, &pre) switch pre.Code { case "free_quota_exhausted": return promptToSubscribe(pre.TopUpURL) case "quota_exhausted": return promptToTopUp(pre.Balance) default: return alertAnOperator(pre) } } ``` ```bash $ envloped send --from you@yourdomain.com --to user@example.com \ --subject "Hello" --text "Hi" API error (402): Your prepaid email balance is exhausted. [quota_exhausted] Hint: 0 emails left. Buy a pack or enable auto-recharge at https://envloped.com/settings/billing ``` ## Auto-recharge [#auto-recharge] Auto-recharge buys a pack automatically when the balance runs out, so a send that would have been refused goes through instead. It is off unless you turn it on, and it has a **ceiling per billing period** that you set — nothing is ever bought above it. Two behaviours worth knowing: * It fires only on `quota_exhausted`. It will not fire on a legacy free account (that would turn a lapsed account into a purchase without anyone deciding to) and not on a blocked account. * **A declined charge switches it off** and emails you. The next send then gets a clean `402` rather than a card retry per message. ## Contacts above your ceiling [#contacts-above-your-ceiling] The marketing product licenses a number of active contacts. A daily job counts them; crossing the ceiling raises it, and the difference is billed immediately. Contacts above the paid ceiling can still be **created, imported and stored** — they are only excluded from campaign audiences until the charge lands. An import of 40,000 contacts onto a 5,000-contact ceiling never fails; the new contacts become mailable after the next daily count. ## If a payment fails [#if-a-payment-fails] | Day | What happens | | --- | ------------------------------------------------------------------------------------------------------------------------------------------- | | 0 | The charge fails. You are emailed | | 1–2 | Sending continues. Daily reminder | | 3 | Sending is blocked — `402 payment_required` | | 7 | Second notice | | 14 | The subscription is cancelled and the account falls back to the legacy free allowance — which, if it was already spent, means nothing sends | | 44 | Unused prepaid credits are forfeited | Paying at any point before day 14 restores everything immediately: sending resumes, paused campaigns unpause, and credits are untouched throughout. ## Legacy plans [#legacy-plans] Accounts on a plan that predates this model keep it, with no end date and no migration. They are not gated on a prepaid balance, and nothing on this page applies to them — their billing page shows the metered view that matches their invoice. # Segments (/docs/segments) # Segments [#segments] A segment is a **saved rule**, not a saved list of people. It stores a definition and re-resolves every time it is counted, previewed, or sent to — including at the moment a scheduled campaign actually fires, not when it was scheduled. A contact who matches tomorrow is included tomorrow, with no intervention. All endpoints require an `Authorization: Bearer YOUR_API_KEY` header. ## The segment object [#the-segment-object] ```json { "id": "sg_abc123", "name": "Singapore · women · 25–35", "description": null, "definition": { "op": "and", "children": [ /* … */ ] }, "condition_count": 3, "created_at": "2026-08-09T10:30:00Z", "updated_at": "2026-08-09T10:30:00Z" } ``` ## The definition grammar [#the-definition-grammar] A definition is a nested boolean tree. **Groups** have an `op` (`and` / `or`) and `children`; **leaves** have a `kind`. ```json { "op": "and", "children": [ { "op": "or", "children": [ { "kind": "attribute", "field": "city", "operator": "eq", "value": "Singapore" }, { "kind": "attribute", "field": "city", "operator": "eq", "value": "Kuala Lumpur" } ]}, { "kind": "attribute", "field": "date_of_birth", "operator": "age_between", "value": [25, 35] }, { "kind": "attribute", "field": "interested_in", "operator": "contains_any", "value": ["women"] }, { "kind": "list", "operator": "in_list", "value": "ls_newsletter" }, { "kind": "engagement", "operator": "opened_in_last_days", "value": 30 }, { "kind": "contact", "field": "status", "operator": "eq", "value": "subscribed" } ] } ``` Limits: **25 conditions**, nested up to **3 levels**. Exceeding either returns `422` naming the limit. An empty group is an error — neither "matches everyone" nor "matches nobody" is safe to guess. ### Leaf kinds [#leaf-kinds] | `kind` | Targets | Operators | | ------------ | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | | `attribute` | A registered [contact field](/docs/contacts/contact-fields) by `field` key | Per the field's type | | `contact` | Core columns: `status`, `created_at`, `email_domain` | `eq`, `neq`, `in`, `not_in`, `before`, `after`, `between`, `in_last_days` | | `list` | List membership | `in_list`, `not_in_list` | | `engagement` | Opens, clicks and receipts | `opened_in_last_days`, `clicked_in_last_days`, `not_opened_in_last_days`, `opened_campaign`, `clicked_campaign`, `received_campaign` | ### Age [#age] `age_between` is an operator on a `date` field and takes `[min, max]` inclusive: ```json { "kind": "attribute", "field": "date_of_birth", "operator": "age_between", "value": [20, 35] } ``` It compiles to a birthdate window evaluated against the current date, so nobody has to re-run anything on a birthday. ### Two semantics worth knowing [#two-semantics-worth-knowing] **Negative attribute operators require the attribute to be present.** `neq`, `not_in` and `not_contains` match only contacts whose attribute is set *and* different. A contact with the attribute missing does **not** match. "Not X" means *we know, and it isn't X* — never *we don't know*. If 3% of your contacts have no `gender` recorded, `gender neq male` will not sweep them in. **Engagement negatives are the opposite.** `not_opened_in_last_days` *does* match a contact with no events at all, because there the absence of a row is the fact: they did not open. Opens are honest opens — proxy/prefetch opens are excluded, matching the campaign report. ## Create a segment [#create-a-segment] ``` POST /v1/segments ``` | Parameter | Type | Required | | ------------- | -------------- | -------- | | `name` | string | Yes | | `description` | string \| null | No | | `definition` | object | Yes | ```bash curl -X POST https://api.envloped.com/v1/segments \ -H "Authorization: Bearer en_abc123..." \ -H "Content-Type: application/json" \ -d '{ "name": "Singapore 25-35", "definition": { "op": "and", "children": [ { "kind": "attribute", "field": "city", "operator": "eq", "value": "Singapore" }, { "kind": "attribute", "field": "date_of_birth", "operator": "age_between", "value": [25, 35] } ] } }' ``` The definition is normalised as it is validated — what is stored is canonical (trimmed strings, coerced numbers, de-duplicated arrays), so read it back rather than assuming it round-trips byte-for-byte. ### From the CLI [#from-the-cli] ```bash envloped segments create "Singapore 25-35" --definition-file ./segment.json # Or pipe it — the shell will mangle a nested tree passed as a --definition string cat segment.json | envloped segments create "Singapore 25-35" --stdin ``` A `422` prints every entry in the `errors` array on its own line as `code: path — message`, so a rejected definition tells you which leaf to fix rather than just that the tree was invalid. ### Errors [#errors] | Status | `error` | Meaning | | ------ | ------------------- | ---------------------------------------------------------------------------------------------- | | `422` | `unknown_field` | A leaf references a field key that is not registered, or has been archived. | | `422` | `invalid_segment` | The tree is malformed, exceeds a limit, or uses an operator the field's type does not support. | | `409` | `duplicate_segment` | A live segment with that name already exists. | Both `422`s include an `errors` array listing every problem found, not just the first. ## Other endpoints [#other-endpoints] | Method | Path | Purpose | | -------- | ----------------------------------- | -------------------------------------------------------------------------- | | `GET` | `/v1/segments` | All live segments. | | `GET` | `/v1/segments/:id` | One segment. | | `PATCH` | `/v1/segments/:id` | Rename, re-describe, or replace the definition. | | `DELETE` | `/v1/segments/:id` | Soft delete. Campaigns that already sent keep their records. | | `GET` | `/v1/segments/:id/count` | How many contacts match right now. | | `GET` | `/v1/segments/:id/preview?limit=25` | Count plus a sample of real contacts. | | `POST` | `/v1/segments/preview` | Evaluate an **unsaved** definition — the endpoint the visual builder uses. | ```bash curl "https://api.envloped.com/v1/segments/sg_abc123/count" \ -H "Authorization: Bearer en_abc123..." # { "id": "sg_abc123", "count": 4210 } ``` The CLI covers the same set: ```bash envloped segments list envloped segments get sg_abc123 envloped segments count sg_abc123 --quiet # bare integer, pipeable envloped segments preview sg_abc123 --limit 25 # Evaluate an unsaved definition — the try-before-you-commit loop envloped segments preview --definition-file ./candidate.json ``` ## What the count does and does not include [#what-the-count-does-and-does-not-include] `count` answers exactly one question: **how many contacts match this definition.** It does not subtract unsubscribes, suppressions, or contacts that are not eligible to be mailed. The number that matters before a send is the campaign-level `eligible` figure from [`GET /v1/campaigns/:id/audience/preview`](/docs/campaigns), which layers those gates on top of this same rule. Conflating the two would make the segment count lie about what the segment is. # Send Email (/docs/send-email) # Send Email [#send-email] Send transactional emails using the Envloped API. ## Endpoint [#endpoint] ``` POST /v1/emails ``` ## Request Headers [#request-headers] | Header | Required | Description | | ----------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Authorization` | Yes | `Bearer YOUR_API_KEY` | | `Content-Type` | Yes | `application/json` | | `Idempotency-Key` | No | Makes the send safe to retry — a replay returns the original response instead of sending a second email. Strongly recommended for anything triggered by user action. See [Idempotency](/docs/idempotency). | ## Request Body [#request-body] ```json { "from": "Your Name ", "to": "recipient@example.com", "subject": "Your subject line", "html": "

Your HTML content

", "text": "Your plain text content", "replyTo": "replies@yourdomain.com", "cc": ["cc@example.com"], "bcc": ["bcc@example.com"], "headers": { "X-Custom-Header": "value" }, "attachments": [ { "filename": "invite.ics", "content": "QkVHSU46VkNBTEVOREFS...", "contentType": "text/calendar" } ] } ``` ### Parameters [#parameters] | Parameter | Type | Required | Description | | ------------- | ------------------- | -------- | ---------------------------------------------------------------------------------------------------------- | | `from` | string | Yes | Sender email address. Must be from a verified domain. Supports `"Name "` format for a display name. | | `to` | string \| string\[] | Yes | Recipient email address(es). | | `subject` | string | Yes | Email subject line. | | `html` | string | No\* | HTML content of the email. | | `text` | string | No\* | Plain text content of the email. | | `replyTo` | string | No | Reply-to email address. | | `cc` | string\[] | No | CC recipient addresses. | | `bcc` | string\[] | No | BCC recipient addresses. | | `headers` | object | No | Custom email headers. | | `attachments` | array | No | File attachments (max 10, total ≤ 40MB). See [Attachments](/docs/attachments). | \*At least one of `html` or `text` is required. ## Response [#response] ### Success (200 OK) [#success-200-ok] ```json { "success": true, "id": "ac5130c0-060b-40e1-ac7d-ab4204628dbc", "messageId": "010e01a0c45bc5c8-fad38ef0-7f65-4f1e-bfd4-43b891835314-000000" } ``` ### Response Fields [#response-fields] | Field | Type | Description | | ----------------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------- | | `success` | boolean | `true` when the email was accepted for sending. | | `id` | string | The email's identifier in Envloped. Pass it to `GET /v1/emails/:id` to read the send back, including its delivery status. | | `messageId` | string | The provider's message id, which delivery and bounce events are keyed on. `GET /v1/emails/:id` accepts this too, so either identifier works. | | `suppressed_recipients` | object\[] | Present only when recipients were skipped because they are on your suppression list. Each entry carries `email`, `reason` and `scope`. | | `suppressed` | boolean | Present, and `true`, only when *every* recipient was suppressed — nothing was sent, and the record's status is `suppressed`. | To read the send back — its status, timestamps, body and attachments — call: ``` GET /v1/emails/{id} ``` The `status` on that record starts at `sent` and advances as the provider reports back: `delivered`, `bounced`, `complained`, or `failed`. Two less obvious values: `suppressed` means every recipient was on your suppression list and nothing was sent, and `unknown` means the provider accepted the message but returned no message id — it was almost certainly delivered, but no delivery events will ever arrive for it, so it is not reported as `sent`. ## Error Responses [#error-responses] ### 400 Bad Request [#400-bad-request] ```json { "error": { "code": "validation_error", "message": "Invalid request body", "details": [ { "field": "to", "message": "Invalid email address format" } ] } } ``` ### 401 Unauthorized [#401-unauthorized] ```json { "error": { "code": "unauthorized", "message": "Invalid or missing API key" } } ``` ### 403 Forbidden [#403-forbidden] ```json { "error": { "code": "domain_not_verified", "message": "The sender domain is not verified" } } ``` ### 429 Too Many Requests [#429-too-many-requests] Returned when a daily or monthly volume limit is reached — see [Limits](#limits). Despite the code name there is no per-minute request rate limit today, so retrying sooner will not help; the limit resets on the day or billing period boundary. ```json { "error": { "code": "rate_limit_exceeded", "message": "Rate limit exceeded. Please try again later.", "retryAfter": 60 } } ``` ## Code Examples [#code-examples] ### cURL [#curl] ```bash curl -X POST https://api.envloped.com/v1/emails \ -H "Authorization: Bearer en_abc123..." \ -H "Content-Type: application/json" \ -d '{ "from": "Your App ", "to": "user@example.com", "subject": "Welcome to our service", "html": "

Welcome!

Thanks for signing up.

", "text": "Welcome! Thanks for signing up." }' ``` ### JavaScript / TypeScript [#javascript--typescript] ```javascript async function sendEmail() { const response = await fetch('https://api.envloped.com/v1/emails', { method: 'POST', headers: { 'Authorization': `Bearer ${process.env.ENVLOPED_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ from: 'Your App ', to: 'user@example.com', subject: 'Welcome to our service', html: '

Welcome!

Thanks for signing up.

', text: 'Welcome! Thanks for signing up.', }), }); if (!response.ok) { const error = await response.json(); throw new Error(error.error.message); } return response.json(); } ``` ### Python [#python] ```python import os import requests def send_email(): response = requests.post( 'https://api.envloped.com/v1/emails', headers={ 'Authorization': f'Bearer {os.environ["ENVLOPED_API_KEY"]}', 'Content-Type': 'application/json', }, json={ 'from': 'Your App ', 'to': 'user@example.com', 'subject': 'Welcome to our service', 'html': '

Welcome!

Thanks for signing up.

', 'text': 'Welcome! Thanks for signing up.', } ) response.raise_for_status() return response.json() ``` ### Go [#go] ```go package main import ( "bytes" "encoding/json" "net/http" "os" ) type EmailRequest struct { From string `json:"from"` To string `json:"to"` Subject string `json:"subject"` HTML string `json:"html"` Text string `json:"text"` } func sendEmail() (*http.Response, error) { email := EmailRequest{ From: "Your App ", To: "user@example.com", Subject: "Welcome to our service", HTML: "

Welcome!

Thanks for signing up.

", Text: "Welcome! Thanks for signing up.", } body, _ := json.Marshal(email) req, _ := http.NewRequest("POST", "https://api.envloped.com/v1/emails", bytes.NewBuffer(body)) req.Header.Set("Authorization", "Bearer "+os.Getenv("ENVLOPED_API_KEY")) req.Header.Set("Content-Type", "application/json") return http.DefaultClient.Do(req) } ``` ## Limits [#limits] Limits are on **recipients**, not on request rate — and not on requests. One call with 50 recipients in `to` costs 50, exactly as 50 calls with one recipient each would. Transactional sends, campaign sends and campaign test sends all draw on the same balance. | Plan | Included per billing period | At the limit | | ---------------------- | ------------------------------------------------------------------------------------ | ---------------------------------- | | Transactional ($10/mo) | 20,000 emails, reset each period | `402 quota_exhausted` — buy a pack | | Marketing ($29/mo) | 75,000 campaign sends — 15× your 5,000 included active contacts | `402 quota_exhausted` — buy a pack | | Legacy free | **100 emails, for the lifetime of the account** — not per month, and it never resets | `402 free_quota_exhausted` | The last row is not a plan you can sign up for. Creating an account takes a payment method and starts a subscription, so the lifetime allowance only describes **legacy and lapsed accounts**: those made before signup took a card, and subscriptions that were cancelled or went unpaid. Volume beyond the included quota is **prepaid**: packs are bought before those emails can be sent, starting at 10,000 emails for $10 and falling to $0.70 and $0.40 per 1,000 at higher monthly volume. Unused pack credits roll over for as long as the subscription is active. Nothing is ever invoiced in arrears, so a send is refused rather than silently adding to a bill. A refusal is a `402` with a machine-readable `code`: | Code | Meaning | | ---------------------- | ------------------------------------------------------------------------------------------------------------------ | | `free_quota_exhausted` | The 100 lifetime free emails are used. Only a legacy or lapsed account can receive this; subscribe to keep sending | | `quota_exhausted` | Included quota and prepaid credits are both at zero. Top up, or turn on auto-recharge | | `payment_required` | An unpaid invoice has passed its grace period and sending is blocked. Update the card | | `account_blocked` | The account is suspended or closed | The body carries the remaining balance and a link to top up, so a client can tell "out of credit" from "not allowed to send" without parsing prose. A send can also be refused for a reason that has nothing to do with money: | Code | Status | Meaning | | ---------------------- | ------ | ---------------------------------------------------------------------------------------- | | `sending_not_approved` | `403` | This workspace has not been reviewed yet. See [Sending approval](/docs/sending-approval) | | `reputation_suspended` | `403` | Bounce or complaint rates crossed the threshold | | `domain_not_verified` | `403` | The `from` domain has no verified DNS | `sending_not_approved` is the one every new account meets first: nothing sends until a human at Envloped has reviewed the workspace, which takes about a day. Like the `402`, **it must never be retried** — it clears when a person makes a decision, not when time passes. While a subscription is `past_due`, sending continues for a short grace period rather than stopping outright — on legacy metered plans that grace is **200 emails per day**. Once the grace period ends, sends are refused with `402 payment_required` until the payment succeeds. There is currently **no per-minute request rate limit**. On legacy metered plans a volume limit still returns `429`; on current plans the balance is the limit and the refusal is a `402`. **Never retry a `402`.** A `429` clears by waiting; this one clears only when money moves, so a client that retries it will retry forever. See [Quotas](/docs/quotas) for how to branch on each code, and for auto-recharge, which prevents most of these refusals in the first place. That said, do not treat the absence of a rate limit as a guarantee. Batch where an endpoint offers batching (see [Contacts](/docs/contacts), which takes 1000 contacts per request), and back off on `429` rather than retrying immediately. # Sending approval (/docs/sending-approval) # Sending approval [#sending-approval] **A new workspace cannot send email until we have reviewed it.** Not transactional API sends, not SMTP, not campaigns, not test sends. Everything else works from the moment you sign up — add domains, verify DNS, mint API keys, import contacts, build campaigns — but nothing leaves the building until the workspace is approved. Most requests are answered within 24 hours. ## Why [#why] Deliverability is shared. Every account that sends from Envloped is sending from IP addresses and domains that every other account's mail also depends on, so one list bought from a broker is not that account's problem — it is everyone's. Reviewing new accounts before their first send is the cheapest point at which that is preventable; afterwards the only tools left are suspension and apology. It also means the review is genuinely about the answers you give us, not about your card or your traffic. A one-line description of where your recipients come from is usually enough. ## Requesting approval [#requesting-approval] Go to **Settings → Sending approval** and answer two questions: **Expected volume** — roughly how many emails you expect to send, or how many contacts you plan to reach. An estimate is fine; nobody holds you to it. **What you plan to send** — the email types and, most importantly, **where the recipients come from**. This is the answer that decides the review. A good answer is specific and boring: > Password resets and order receipts from our app, plus an optional product > newsletter for customers who tick a box at checkout. A weak answer is one that could describe anybody: > Marketing emails to our users. You can withdraw a pending request and edit it. If a request is declined you will get the reason by email, and you can submit a new one after 24 hours. ## The API refusal [#the-api-refusal] While a workspace is unapproved, every send path answers `403`: ```json { "error": "This workspace has not been approved to send email yet. Request approval in Settings → Sending approval; most requests are answered within 24 hours.", "code": "sending_not_approved", "approval_status": "not_requested", "approval_url": "https://envloped.com/settings/sending-approval" } ``` `approval_status` is one of `not_requested`, `pending` or `rejected`, and the three want different things from you — respectively: fill in the form, wait, and read the emailed reason. ### Never retry it [#never-retry-it] Same rule as [`402`](/docs/quotas#never-retry-a-402), for a stronger reason. A `429` clears by waiting and a `402` clears when money moves; this one clears only when a person at Envloped makes a decision. A backoff loop against a review queue is a slower way to do nothing. If your client has a blanket retry policy for "server said no", make sure neither `402` nor `403` is in it. The Envloped SDKs and the CLI do not retry either. ```ts import { ForbiddenError } from '@envloped/envloped-js' try { await envloped.emails.send({ from, to, subject, html }) } catch (err) { if (err instanceof ForbiddenError && err.isSendingNotApproved) { // Not a transient failure. Surface it to a human, do not retry. console.error(`Sending is not approved yet: ${err.approvalUrl}`) } } ``` ```go _, err := client.Emails.Send(ctx, req) if errors.Is(err, envloped.ErrSendingNotApproved) { // Not a transient failure. Surface it to a human, do not retry. log.Printf("sending is not approved yet") } ``` ## How this differs from the other refusals [#how-this-differs-from-the-other-refusals] Four things can stop a send, and they are deliberately distinguishable because the remedies have nothing in common: | Code | Status | What it means | What fixes it | | ----------------------------------------------------------- | ------------- | ----------------------------------------------- | ------------------------------- | | `sending_not_approved` | `403` | Nobody has reviewed this workspace | Fill in the form, then wait | | `reputation_suspended` | `403` | Bounce or complaint rates crossed the threshold | Clean the list, contact support | | `account_suspended` / `account_closed` | `403` / `401` | The account itself is blocked | Contact support | | [the four `402` codes](/docs/quotas#when-a-send-is-refused) | `402` | You cannot pay for this send | Money | Nothing on this page applies to accounts that were already sending before review existed — they are approved, and always were. # Command Reference (/docs/cli/commands) # Command Reference [#command-reference] ## Authentication [#authentication] ### `envloped login` [#envloped-login] Authenticate with your Envloped API key. ```bash envloped login --api-key en_xxxxx # Direct flag envloped login # Interactive prompt ``` ### `envloped logout` [#envloped-logout] Clear stored credentials for the current profile. ```bash envloped logout envloped logout --profile staging ``` ### `envloped whoami` [#envloped-whoami] Show the authenticated user and organization info. ```bash envloped whoami envloped whoami --json ``` ## Sending Emails [#sending-emails] ### `envloped send` [#envloped-send] Send an email from the terminal. ```bash # Inline text (with sender name) envloped send --from "Your Name " --to user@example.com \ --subject "Welcome!" --text "Hello world" # HTML body envloped send --from "Your Name " --to user@example.com \ --subject "Welcome!" --html "

Hello

" # From file envloped send --from "Your Name " --to user@example.com \ --subject "Newsletter" --html-file template.html # JSON stdin (best for CI/CD and agents) echo '{"from":"Your Name ","to":"c@d.com","subject":"Hi","text":"Hello"}' | envloped send --stdin # Multiple recipients envloped send --from a@b.com --to user1@example.com --to user2@example.com \ --subject "Broadcast" --text "Hello all" # Dry run (validate without sending) envloped send --from a@b.com --to user@example.com --subject "Test" --text "Hi" --dry-run ``` | Flag | Description | | ------------- | ------------------------------------------------------- | | `--from` | Sender email address (supports `"Name "` format) | | `--to` | Recipient email address (repeatable) | | `--subject` | Email subject | | `--html` | HTML body | | `--text` | Plain text body | | `--html-file` | Read HTML body from file | | `--text-file` | Read text body from file | | `--stdin` | Read full email payload as JSON from stdin | | `--dry-run` | Validate without sending | ## Email Management [#email-management] ### `envloped emails list` [#envloped-emails-list] List sent emails with optional filtering. ```bash envloped emails list envloped emails list --status sent envloped emails list --status failed --limit 10 envloped emails list --search "welcome" envloped emails list --json ``` | Flag | Description | | ---------- | --------------------------------------------- | | `--status` | Filter by status: `sent`, `failed`, `bounced` | | `--search` | Search by recipient address or subject | | `--limit` | Number of results (default: 20) | | `--page` | Page number (default: 1) | ### `envloped emails get [id]` [#envloped-emails-get-id] Get details for a specific email. ```bash envloped emails get abc123 envloped emails get abc123 --json ``` ## Domain Management [#domain-management] ### `envloped domains list` [#envloped-domains-list] List all domains for your account. ```bash envloped domains list envloped domains list --json ``` ### `envloped domains add [domain]` [#envloped-domains-add-domain] Add a new domain and display DNS records for verification. ```bash envloped domains add mydomain.com ``` ### `envloped domains verify [domain]` [#envloped-domains-verify-domain] Check the verification status of a domain. ```bash envloped domains verify mydomain.com ``` ### `envloped domains records [domain]` [#envloped-domains-records-domain] Re-display DNS records needed to verify a domain. ```bash envloped domains records mydomain.com ``` ## Contacts [#contacts] Manage marketing contacts (subscribers). See the [Contacts API](/docs/contacts) for the underlying endpoints. ### `envloped contacts list` [#envloped-contacts-list] List contacts with optional filtering. ```bash envloped contacts list envloped contacts list --status subscribed envloped contacts list --list ls_newsletter --search "acme" envloped contacts list --limit 50 --page 2 --json ``` | Flag | Description | | ---------- | ------------------------------------------------------------------------------------------------ | | `--status` | Filter by status: `subscribed`, `unsubscribed`, `bounced`, `complained`, `suppressed`, `pending` | | `--list` | Filter by list ID | | `--search` | Search by email | | `--limit` | Number of results (default: 20) | | `--page` | Page number (default: 1) | ### `envloped contacts get [id]` [#envloped-contacts-get-id] Show a contact's details and its list memberships. ```bash envloped contacts get ct_abc123 envloped contacts get ct_abc123 --json ``` ### `envloped contacts create [email]` [#envloped-contacts-create-email] Create a contact, optionally adding it to lists. ```bash envloped contacts create jane@example.com envloped contacts create jane@example.com --first-name Jane --last-name Doe envloped contacts create jane@example.com --list ls_newsletter --list ls_beta ``` | Flag | Description | | ------------------ | ----------------------------------------------------- | | `--first-name` | Contact first name | | `--last-name` | Contact last name | | `--list` | List ID to add the contact to (repeatable) | | `--consent-source` | Consent source: `api`, `csv_import`, `form`, `manual` | ### `envloped contacts update [id]` [#envloped-contacts-update-id] Update a contact's name or status. ```bash envloped contacts update ct_abc123 --first-name Jane envloped contacts update ct_abc123 --status unsubscribed ``` | Flag | Description | | -------------- | -------------- | | `--first-name` | New first name | | `--last-name` | New last name | | `--status` | New status | ### `envloped contacts delete [id]` [#envloped-contacts-delete-id] Permanently delete a contact (irreversible GDPR hard delete). Requires confirmation unless `--force` is used. ```bash envloped contacts delete ct_abc123 envloped contacts delete ct_abc123 --force ``` ### `envloped contacts export` [#envloped-contacts-export] Export all contacts as CSV to stdout or a file. ```bash envloped contacts export envloped contacts export --output contacts.csv ``` | Flag | Description | | ---------- | ------------------------------------- | | `--output` | Write CSV to a file instead of stdout | ### `envloped contacts import [file]` [#envloped-contacts-import-file] Bulk create or update contacts from CSV or JSON. Reads stdin when the file argument is omitted or `-`. ```bash envloped contacts import contacts.csv envloped contacts import contacts.csv --list ls_abc123 --member-status approved cat contacts.json | envloped contacts import --format json ``` | Flag | Description | | ------------------- | ---------------------------------------------------------------------------------------------- | | `--format` | `auto` (default), `csv`, or `json`. Sniffed from the extension, then the first byte | | `--list` | List ID to add every imported contact to (repeatable) | | `--member-status` | Status for rows that do not carry one — `approved` (default), `rejected`, `pending`, `removed` | | `--attributes-mode` | `merge` (default, server-side) or `replace` | | `--idempotency-key` | A replay returns the stored receipt instead of re-running | | `--batch-size` | Rows per request. Default and maximum 1000 | **CSV shape.** A header row is required. `email`, `first_name`, `last_name`, `member_status` and `list_ids` (`;` or `|` separated) map to fields; **every other column becomes an attribute under its verbatim header**, validated against your [contact fields](/docs/contacts/contact-fields). A blank attribute cell is omitted rather than sent, because the server reads `""` as *clear this attribute*. Read-only export columns (`id`, `status`, `consent_*`, `created_at`) are ignored, and the ignored set is printed — so an `export` → `import` round trip does not become a wall of `unknown_fields`. **Reporting.** Two tables follow the counts: rows that **did not import**, and rows that **imported with data dropped** (unknown fields, invalid fields, invalid list ids). The second table matters — those rows succeed, so it is the only place a dropped `list_id` is visible, and a silently dropped list id is how you mail the wrong audience. The command exits non-zero if any row failed. Requests larger than `--batch-size` are chunked, and each chunk gets its own suffixed idempotency key (`key-1`, `key-2`); one key reused across chunks would make chunk 2 look like a replay of chunk 1 and return chunk 1's receipt. ### `envloped contacts activity [id]` [#envloped-contacts-activity-id] Show a contact's per-campaign engagement history — every campaign they were sent, with opens, clicks, bounces, complaints, and unsubscribes, plus totals. ```bash envloped contacts activity ct_abc123 envloped contacts activity ct_abc123 --json ``` The table shows **proxy-excluded** opens. Raw opens are reported separately in the footer, because Apple Mail Privacy Protection prefetches images and inflates them. ## Lists [#lists] Manage contact lists and membership. See the [Lists API](/docs/lists) for the underlying endpoints. ### `envloped lists list` [#envloped-lists-list] List all lists with member counts. ```bash envloped lists list envloped lists list --json ``` ### `envloped lists get [id]` [#envloped-lists-get-id] Show a list's details. ```bash envloped lists get ls_newsletter ``` ### `envloped lists create [name]` [#envloped-lists-create-name] Create a list. ```bash envloped lists create "Newsletter" envloped lists create "Newsletter" --description "Weekly product digest" ``` | Flag | Description | | --------------- | ---------------- | | `--description` | List description | ### `envloped lists update [id]` [#envloped-lists-update-id] Rename or re-describe a list. ```bash envloped lists update ls_newsletter --name "Weekly Newsletter" envloped lists update ls_newsletter --description "Every Monday" ``` | Flag | Description | | --------------- | --------------- | | `--name` | New name | | `--description` | New description | ### `envloped lists delete [id]` [#envloped-lists-delete-id] Soft-delete a list (contacts are untouched). Requires confirmation unless `--force` is used. ```bash envloped lists delete ls_newsletter envloped lists delete ls_newsletter --force ``` ### `envloped lists members [id]` [#envloped-lists-members-id] List the members of a list. ```bash envloped lists members ls_newsletter envloped lists members ls_newsletter --limit 100 --page 2 ``` | Flag | Description | | --------- | ------------------------------- | | `--limit` | Number of results (default: 20) | | `--page` | Page number (default: 1) | ### `envloped lists add [id] [contactID...]` [#envloped-lists-add-id-contactid] Add one or more existing contacts to a list. ```bash envloped lists add ls_newsletter ct_abc123 envloped lists add ls_newsletter ct_abc123 ct_def456 ``` ### `envloped lists remove [id] [contactID...]` [#envloped-lists-remove-id-contactid] Remove one or more contacts from a list (membership only — the contacts are not deleted). ```bash envloped lists remove ls_newsletter ct_def456 ``` ## Segments [#segments] Build and evaluate audience definitions. See the [Segments API](/docs/segments) for the definition grammar. ### `envloped segments list|get|count` [#envloped-segments-listgetcount] ```bash envloped segments list envloped segments get sg_abc123 envloped segments count sg_abc123 envloped segments count sg_abc123 --quiet # bare integer, pipeable ``` ### `envloped segments create [name]` [#envloped-segments-create-name] ```bash envloped segments create "Singapore 25-35" --definition-file ./segment.json cat segment.json | envloped segments create "Singapore 25-35" --stdin ``` | Flag | Description | | ------------------- | --------------------------------------------------- | | `--definition` | Condition tree as inline JSON | | `--definition-file` | Read the condition tree from a file (`-` for stdin) | | `--stdin` | Read the condition tree from stdin | | `--description` | Segment description | The three input modes are mutually exclusive and one is required. Prefer a file: a definition is a nested boolean tree, and a shell will mangle the quoting on a `--definition` string. Only JSON *syntax* is checked locally — the grammar is owned by the API, and duplicating it in the CLI is how two systems drift into disagreeing about who is in an audience. A `422` prints every entry from the response's `errors` array on its own line as `code: path — message`, so a rejected tree tells you which leaf to fix. ### `envloped segments update [id]` [#envloped-segments-update-id] ```bash envloped segments update sg_abc123 --name "Singapore 25-40" envloped segments update sg_abc123 --definition-file ./revised.json envloped segments update sg_abc123 --clear-description ``` | Flag | Description | | ------------------------------------------------ | ------------------------------------------------ | | `--name` | New segment name | | `--description` / `--clear-description` | Set or clear the description. Cannot be combined | | `--definition` / `--definition-file` / `--stdin` | Replacement condition tree | Only flags you pass are sent. Replacing the definition changes **who a campaign targeting this segment will reach** — a segment stores a rule, not a snapshot, so a scheduled campaign resolves the new rule when it fires. The command says so when you change one. ### `envloped segments delete [id]` [#envloped-segments-delete-id] ```bash envloped segments delete sg_abc123 envloped segments delete sg_abc123 --yes ``` Soft delete. Campaigns that already sent keep their records; campaigns still targeting it lose an audience source, and a send whose only include source is gone fails with `dead_source` rather than quietly mailing nobody. Confirms first, unless `--yes` or a non-TTY stdout. ### `envloped segments preview [id]` [#envloped-segments-preview-id] Count and sample a definition. With an id it previews a saved segment; without one it evaluates an **unsaved** tree without persisting it — the try-before-you-commit loop. ```bash envloped segments preview sg_abc123 --limit 25 envloped segments preview --definition-file ./candidate.json ``` | Flag | Description | | ------------------------------------------------ | ---------------------------------------- | | `--limit` | Sample size (server default 25, max 100) | | `--definition` / `--definition-file` / `--stdin` | Preview an unsaved tree | ## Campaigns [#campaigns] Draft, target, test, and send marketing campaigns. See the [Campaigns API](/docs/campaigns). ### `envloped campaigns list|get|cancel` [#envloped-campaigns-listgetcancel] ```bash envloped campaigns list --page 1 --limit 25 envloped campaigns get cp_abc123 envloped campaigns cancel cp_abc123 ``` ### `envloped campaigns create [name]` [#envloped-campaigns-create-name] ```bash envloped campaigns create "August newsletter" \ --from "You " \ --subject "What shipped in August" \ --html-file ./newsletter.html \ --include segment:sg_abc123 --exclude segment:sg_churned ``` | Flag | Description | | ------------------------- | ------------------------------------------------------------- | | `--from` | Sender address | | `--reply-to` | Reply-to address | | `--subject` | Subject line | | `--html` / `--html-file` | HTML body, inline or from a file | | `--source-format` | `html`, `visual`, or `markdown` | | `--source-body` | Body source, when `--source-format markdown` | | `--template` | Seed the content from a saved template | | `--include` / `--exclude` | Audience source `:` (repeatable) | ### `envloped campaigns update [id]` [#envloped-campaigns-update-id] Takes the same content flags as `create`, plus `--name`. Nullable fields are three-state: an unset flag is omitted, `--reply-to ""` clears the column, and any other value replaces it. ### `envloped campaigns audience|preview` [#envloped-campaigns-audiencepreview] ```bash envloped campaigns audience cp_abc123 # show envloped campaigns audience cp_abc123 --include segment:sg_abc123 # replace envloped campaigns preview cp_abc123 --limit 25 ``` `audience` with no `--include`/`--exclude` reads; with either, it **replaces the whole audience** rather than appending. `preview` resolves the audience to an eligible recipient count plus a sample, applying the unsubscribe and suppression gates that a raw segment `count` does not. ### `envloped campaigns test [id]` [#envloped-campaigns-test-id] ```bash envloped campaigns test cp_abc123 --to you@yourdomain.com ``` ### `envloped campaigns send [id]` [#envloped-campaigns-send-id] ```bash envloped campaigns send cp_abc123 envloped campaigns send cp_abc123 --at 2026-08-11T02:00:00Z envloped campaigns send cp_abc123 --yes # CI ``` | Flag | Description | | ------- | --------------------------------------------- | | `--at` | ISO-8601 timestamp at least 60s in the future | | `--yes` | Skip the confirmation prompt | This is the one CLI verb that reaches thousands of inboxes and cannot be undone, so it confirms first — printing the campaign, subject, sender, and the eligible recipient count from the audience preview before asking. The prompt is **skipped automatically when stdout is not a terminal**, so CI is unaffected without `--yes`. A refused send renders the audience snapshot the error carries — included, excluded, eligible, and the per-reason drop breakdown — not just the message. ### `envloped campaigns delete [id]` [#envloped-campaigns-delete-id] ```bash envloped campaigns delete cp_abc123 envloped campaigns delete cp_abc123 --yes ``` Deletes a draft, canceled, failed, or sent campaign **together with its recipients and events** — the record of what was actually sent, and to whom. An in-flight campaign must be cancelled first. Confirms before proceeding, unless `--yes` or a non-TTY stdout. ### `envloped campaigns report [id]` [#envloped-campaigns-report-id] ```bash envloped campaigns report cp_abc123 envloped campaigns report cp_abc123 --section summary --section links envloped campaigns report cp_abc123 --json ``` | Flag | Description | | ----------- | ----------------------------------------------------------------------------------- | | `--section` | Repeatable: `summary`, `funnel`, `links`, `timeseries`, `activity`, `all` (default) | The CLI defaults to the **full** report — a human scrolling 72 rows pays nothing. Its MCP counterpart defaults to `summary` for the opposite reason. Same data, opposite verbosity defaults. Headline open figures are the **proxy-excluded** ones. The raw rate appears once, labelled, because Apple Mail Privacy Protection prefetches images and inflates it — on a real campaign that is the difference between an 80.6% open rate and a 62.9% one. ## Templates [#templates] ```bash envloped templates list # TYPE column marks system vs custom envloped templates list --mine # hide the Envloped starters envloped templates get tpl_abc123 envloped templates create "Monthly digest" --html-file ./digest.html envloped templates update tpl_abc123 --name "Monthly digest v2" envloped templates delete tpl_abc123 --force ``` | Flag | Command | Description | | -------------------------------------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------- | | `--mine` | `list` | Hide the Envloped system starters | | `--from-campaign` | `create` | Copy the source from an existing campaign | | `--body-file` / `--html-file` / `--design-file` | `create` | Editable source, compiled HTML, or visual-editor design JSON | | `--stdin` | `create` | Read the body from stdin. Routed to `design_json` when `--source-format visual`, otherwise to the source body | | `--source-format` | `create` | `html`, `markdown`, or `visual` | | `--description` / `--category` / `--thumbnail-url` | `create`, `update` | Metadata | | `--clear-description` / `--clear-category` | `update` | Clear a field. Cannot be combined with its setter | System starters (`TYPE = system`) are readable but not writable — the `TYPE` column is the discovery path, rather than a write failing. `update` is metadata-only by design; body edits go through a campaign. ## Contact Fields [#contact-fields] The typed attribute registry that governs what `attributes` may contain. See [Contact fields](/docs/contacts/contact-fields). ```bash envloped fields list envloped fields list --include-archived envloped fields get fld_abc123 envloped fields create plan --label "Plan" --type text envloped fields create tier --label "Tier" --type enum --option free --option pro envloped fields update fld_abc123 --label "Pricing tier" envloped fields archive fld_abc123 ``` | Flag | Description | | -------------------- | -------------------------------------------------------- | | `--include-archived` | Include archived fields in `list` | | `--label` | Human-readable label | | `--type` | `text`, `number`, `date`, `boolean`, `enum`, `list` | | `--option` | Allowed value for an `enum` or `list` field (repeatable) | `archive` never drops data — values stay on the contacts and reappear if the key is re-created. ## Suppressions [#suppressions] Addresses Envloped refuses to mail. ```bash envloped suppressions list --reason complaint --limit 50 envloped suppressions get user@example.com envloped suppressions add user@example.com --notes "emailed support 2026-08-24" cat unsubscribes.txt | envloped suppressions add --stdin envloped suppressions delete user@example.com ``` | Flag | Command | Description | | ---------- | -------- | --------------------------------------------------- | | `--reason` | `list` | `hard_bounce`, `complaint`, `manual`, `unsubscribe` | | `--origin` | `list` | `system`, `api`, `dashboard`, `import` | | `--search` | `list` | Search by address | | `--reason` | `add` | `manual` or `unsubscribe` only | | `--notes` | `add` | Free-text note stored with the suppression | | `--stdin` | `add` | Read addresses from stdin, one per line | | `--yes` | `delete` | Skip the confirmation prompt | The two write directions are deliberately asymmetric. **Adding** is the safe move — it stops mail, and it is what you run when someone asks to be removed out-of-band, so it needs no confirmation. **Removing** re-enables mail to someone who bounced, complained, or asked you to stop, so it prompts (skipped by `--yes` and when stdout is not a terminal), and the server refuses complaint-reason removals outright. On `list`, an unrecognised `--reason` or `--origin` is dropped server-side rather than rejected, so a typo returns the whole unfiltered list with a `200`. ## Marketing Profile [#marketing-profile] Your business identity. **A campaign send fails its compliance gate without a complete profile**, and this is how you find out which field is missing without reading a 403 body. ```bash envloped profile get envloped profile set --legal-name "Acme Pte Ltd" --address-line1 "1 Raffles Place" \ --city Singapore --postal-code 048616 --country SG ``` | Flag | Description | | ------------------------------------- | -------------------------------------- | | `--legal-name` | Registered legal name (required) | | `--dba` | Trading name, if different | | `--address-line1` / `--address-line2` | Street address (line 1 required) | | `--city` | Required | | `--region` | State or province | | `--postal-code` | Required | | `--country` | Required | | `--reply-to` | Default reply-to address for campaigns | `get` prints the server's completeness verdict, and when incomplete it names the missing flags and prints a ready-to-paste `profile set` line. On `set`, only flags you actually passed are sent, so unset fields are preserved and `--flag ""` clears. ## Sender Reputation [#sender-reputation] ```bash envloped reputation envloped reputation --json ``` Shows a status banner (healthy / warning / suspended), the SES tenant sending status, and 24h/7d bounce and complaint rates **against the thresholds the server echoes back**, with a per-row verdict — so a number is interpretable rather than just a number. When a send is refused with `reputation_suspended`, this is how you see why and how bad it is. Recovery is manual: a suspension does not clear on its own, and the command says so rather than implying it will. ## API Key Management [#api-key-management] ### `envloped keys list` [#envloped-keys-list] List all API keys for your account. ```bash envloped keys list envloped keys list --json ``` ### `envloped keys create [name]` [#envloped-keys-create-name] Create a new API key. The key is only displayed once. ```bash envloped keys create "Production Key" envloped keys create "CI Pipeline Key" --json ``` ### `envloped keys delete [id]` [#envloped-keys-delete-id] Delete an API key. Requires confirmation unless `--force` is used. ```bash envloped keys delete abc123 envloped keys delete abc123 --force ``` ## MCP Server [#mcp-server] ### `envloped mcp` [#envloped-mcp] Run the CLI as a [Model Context Protocol server](/docs/mcp), exposing 20 tools to Claude, Cursor, and any MCP client. ```bash envloped mcp # stdio, for a local client envloped mcp --http --addr :8080 # Streamable HTTP, for a hosted deployment ``` | Flag | Description | | -------- | --------------------------------------------------------------- | | `--http` | Serve Streamable HTTP instead of stdio | | `--addr` | Listen address for `--http`. Defaults to `:$PORT`, then `:8080` | Over stdio it authenticates with the API key `envloped login` already stored, so no key goes in your MCP client's config file. See [MCP Server](/docs/mcp) for client setup and the full tool table. Note the deliberate asymmetry with the commands above: **the CLI can do things the MCP tools cannot.** `campaigns send`, `keys create`, every `delete`, and `suppressions add`/`delete` are reachable here and are not tools. A human at a terminal is an acceptable operator for bulk sends, credential minting, and compliance edits; an autonomous agent is not. ## Configuration [#configuration] ### `envloped config set [key] [value]` [#envloped-config-set-key-value] Set a configuration value. ```bash envloped config set default_from hello@mydomain.com envloped config set api_url https://api.envloped.com ``` ### `envloped config get [key]` [#envloped-config-get-key] Get a configuration value. ```bash envloped config get default_from ``` Valid keys: `api_url`, `default_from`, `output_format` ## Shell Completions [#shell-completions] ### `envloped completion [shell]` [#envloped-completion-shell] Generate shell completion scripts. ```bash # Bash source <(envloped completion bash) # Zsh source <(envloped completion zsh) # Fish envloped completion fish | source ``` ## Other [#other] ### `envloped version` [#envloped-version] Print the CLI version, commit, and build date. ```bash envloped version # envloped v0.1.0 (commit: abc123, built: 2026-03-28) ``` # CLI Getting Started (/docs/cli) # Envloped CLI [#envloped-cli] The Envloped CLI lets you send emails, manage domains, and API keys directly from your terminal or CI/CD pipelines. ## Installation [#installation] ### curl (Linux / macOS) [#curl-linux--macos] ```bash curl -fsSL https://envloped.com/install.sh | bash ``` ### Manual download [#manual-download] Download the latest binary from [GitHub Releases](https://github.com/glebshbv/envloped/releases) for your platform. ## Quick Start [#quick-start] ### 1. Authenticate [#1-authenticate] ```bash envloped login --api-key en_YOUR_API_KEY ``` Or interactively: ```bash envloped login > Paste your API key: **** > Logged in as you@company.com at Company (pro plan) ``` ### 2. Send your first email [#2-send-your-first-email] ```bash envloped send \ --from "Your Name " \ --to user@example.com \ --subject "Hello from the CLI" \ --text "This email was sent from the terminal!" ``` ### 3. Check status [#3-check-status] ```bash envloped emails list ``` ## Configuration [#configuration] Configuration is stored in `~/.config/envloped/config.toml`. Set defaults: ```bash envloped config set default_from hello@yourdomain.com envloped config set api_url https://api.envloped.com ``` ## Environment Variables [#environment-variables] For CI/CD, use environment variables instead of `envloped login`: | Variable | Description | | ------------------ | ---------------------------- | | `ENVLOPED_API_KEY` | API key for authentication | | `ENVLOPED_API_URL` | Override API base URL | | `ENVLOPED_PROFILE` | Configuration profile to use | ## Global Flags [#global-flags] All commands support these flags: | Flag | Description | | ----------- | --------------------------------------------------------- | | `--json` | Output in JSON format (for piping to `jq`, scripts, etc.) | | `--quiet` | Suppress non-essential output | | `--api-key` | Override API key for this command | | `--profile` | Use a specific configuration profile | | `--api-url` | Override API base URL | # Contact fields (/docs/contacts/contact-fields) # Contact fields [#contact-fields] A contact field is a **typed** definition for one key inside a contact's `attributes` object. Registering `city` as an `enum` and `date_of_birth` as a `date` is what turns free-form JSON into something you can segment on with real operators — date ranges, age windows, list membership — and what stops `jkt` and `Jakarta` becoming two different cities on import. Every field key is also a merge tag: a field with key `city` can be referenced in a campaign as `{{ city }}`. All endpoints require an `Authorization: Bearer YOUR_API_KEY` header. ## The field object [#the-field-object] ```json { "id": "cf_abc123", "key": "date_of_birth", "label": "Date of birth", "type": "date", "options": null, "archived_at": null, "created_at": "2026-08-09T10:30:00Z", "updated_at": "2026-08-09T10:30:00Z" } ``` | Field | Type | Description | | ------------- | ----------------- | ----------------------------------------------------------------------------------------------------------------------------- | | `key` | string | The `attributes` key and the merge-tag name. Lowercase snake\_case, starts with a letter. **Immutable.** | | `label` | string | Human label shown in the dashboard. | | `type` | string | One of `text`, `number`, `date`, `boolean`, `enum`, `list`. **Immutable.** | | `options` | string\[] \| null | For `enum` and `list`: the allowed vocabulary. `null` means an open vocabulary. | | `archived_at` | string \| null | Set when the field is archived. Archived fields keep their data but disappear from pickers and can no longer be segmented on. | ## Types and what they accept [#types-and-what-they-accept] | Type | Stored as | Segment operators | | --------- | ------------------------------ | -------------------------------------------------------------- | | `text` | string | `eq`, `neq`, `contains`, `starts_with`, `is_set`, `is_not_set` | | `number` | number | `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `between` | | `date` | ISO-8601 `"YYYY-MM-DD"` string | `before`, `after`, `between`, `in_last_days`, `age_between` | | `boolean` | `true` / `false` | `is_true`, `is_false` | | `enum` | string, must be in `options` | `eq`, `neq`, `in`, `not_in` | | `list` | array of strings | `contains_any`, `contains_all`, `not_contains` | **Age is never stored.** Register a `date` field for the birthday and use `age_between` when segmenting — it is evaluated against today's date, so a contact leaves the "25–35" segment on their birthday with no intervention from you. See [Segments](/docs/segments). ## List fields [#list-fields] ``` GET /v1/contact-fields ``` Returns live fields first, then archived ones, each alphabetical by key. ```json { "fields": [ /* field objects */ ] } ``` ## Create a field [#create-a-field] ``` POST /v1/contact-fields ``` | Parameter | Type | Required | Description | | --------- | --------- | -------- | --------------------------------------------------------------------- | | `key` | string | Yes | Lowercase snake\_case, ≤ 63 characters. | | `label` | string | Yes | Human label. | | `type` | string | Yes | One of the six types above. | | `options` | string\[] | No | Required vocabulary for `enum` / `list`; omit for an open vocabulary. | ```bash curl -X POST https://api.envloped.com/v1/contact-fields \ -H "Authorization: Bearer en_abc123..." \ -H "Content-Type: application/json" \ -d '{ "key": "city", "label": "City", "type": "enum", "options": ["Singapore", "Kuala Lumpur", "Jakarta", "Bangkok", "Manila"] }' ``` Creating a key that exists but is archived **revives** it rather than failing — otherwise an archived key would be burned forever. ## Update a field [#update-a-field] ``` PATCH /v1/contact-fields/:id ``` `label` and `options` can change. `key` and `type` cannot: changing a type would reinterpret every value already stored under that key. To change a type, archive the field and create a new one. Removing a value from an `enum`'s `options` does not rewrite contacts that already hold it — it only stops new writes from using it. ## Archive a field [#archive-a-field] ``` DELETE /v1/contact-fields/:id ``` This **archives**, it never drops data. The values stay in `contacts.attributes` and reappear if the key is re-created. Segments that reference an archived field stop resolving and report `unknown_field` rather than silently matching nobody. ## How validation works on write [#how-validation-works-on-write] Once a field exists, every write to `attributes` is checked against it: * Values are coerced to the field's type where unambiguous (`"42"` → `42` for a `number`, `"true"` → `true` for a `boolean`). * A value that cannot be coerced is rejected. * An `enum` or `list` value outside a declared `options` vocabulary is rejected. * An empty or whitespace-only value **clears** the attribute to `null` rather than storing `""` — which is what keeps `is_set` meaningful. * An unknown key — one with no registered field — is rejected. A typo'd key is how a segment quietly stops matching, so it is an error rather than a silent create. On [`PATCH /v1/contacts/:id`](/docs/contacts) an invalid attribute returns `422`. On [`POST /v1/contacts/batch`](/docs/contacts) the offending **row** is marked `invalid` and the rest of the batch still applies — one bad key must not discard 999 good contacts. ## From the CLI [#from-the-cli] ```bash envloped fields list envloped fields list --include-archived envloped fields get fld_abc123 envloped fields create plan --label "Plan" --type text envloped fields create tier --label "Tier" --type enum --option free --option pro envloped fields update fld_abc123 --label "Pricing tier" envloped fields archive fld_abc123 ``` The registry is also readable by an AI agent without a tool call — the MCP server exposes it as the resource `envloped://contact-fields`, so a model can read your real field keys and types before composing a segment instead of guessing at them. See [MCP Server](/docs/mcp). # Contacts (/docs/contacts) # Contacts [#contacts] Contacts are the subscribers in your marketing audience. Every contact belongs to your account, carries a consent record, and has a lifecycle `status`. Contacts can be grouped into [Lists](/docs/lists) and targeted with [Segments](/docs/segments). All endpoints require an `Authorization: Bearer YOUR_API_KEY` header. ## The contact object [#the-contact-object] ```json { "id": "ct_abc123", "email": "jane@example.com", "email_normalized": "jane@example.com", "first_name": "Jane", "last_name": "Doe", "attributes": { "plan": "pro" }, "status": "subscribed", "eligibility": "eligible", "eligibility_reason": null, "consent_source": "api", "consent_at": "2026-01-15T10:30:00Z", "unsubscribed_at": null, "created_at": "2026-01-15T10:30:00Z", "updated_at": "2026-01-15T10:30:00Z" } ``` | Field | Type | Description | | -------------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `id` | string | Unique contact identifier. | | `email` | string | Email address as provided. | | `email_normalized` | string | Normalized address used for de-duplication and suppression cross-checks. | | `first_name` | string \| null | First name. | | `last_name` | string \| null | Last name. | | `attributes` | object | Your own key/value pairs (e.g. `{ "plan": "pro" }`). Each key becomes a merge tag (`{{ plan }}`) and a segmentation field. Keys are validated against your registered [contact fields](/docs/contacts/contact-fields) — an unregistered key is rejected, not silently stored. Defaults to `{}`. | | `status` | string | One of `subscribed`, `unsubscribed`, `bounced`, `complained`, `suppressed`, `pending`. | | `eligibility` | string | `eligible` or `ineligible`. An ineligible contact can never receive a campaign, regardless of segments or list membership. See [Eligibility](#eligibility). | | `eligibility_reason` | string \| null | Why, if ineligible. | | `consent_source` | string \| null | How consent was captured: `api` (created via this API), `csv_import`, `form`, or `manual` (added in the dashboard). | | `consent_at` | string \| null | ISO 8601 timestamp consent was recorded. | | `unsubscribed_at` | string \| null | ISO 8601 timestamp of global opt-out, if any. | | `created_at` | string | ISO 8601 creation timestamp. | | `updated_at` | string | ISO 8601 last-update timestamp. | ## List contacts [#list-contacts] ``` GET /v1/contacts ``` Returns a paginated page of contacts. ### Query parameters [#query-parameters] | Parameter | Type | Description | | --------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------- | | `page` | number | 1-based page number (default `1`). | | `limit` | number | Page size, 1–100 (default `50`). | | `status` | string | Filter by lifecycle status. | | `list_id` | string | Restrict to members of a given list. | | `search` | string | Case-insensitive email substring match. | | `since` | string | ISO 8601. Return only contacts updated at or after this time, ordered by `(updated_at, id)`. See [Polling for changes](#polling-for-changes). | | `cursor` | string | Keyset cursor from the previous page's `next_cursor`. Use with `since`. | ### Response [#response] ```json { "contacts": [ /* contact objects */ ], "total": 1280, "page": 1, "totalPages": 26 } ``` ```bash curl "https://api.envloped.com/v1/contacts?status=subscribed&limit=50" \ -H "Authorization: Bearer en_abc123..." ``` ## Create a contact [#create-a-contact] ``` POST /v1/contacts ``` Creates a single contact and, optionally, adds it to lists. This is a strict create — a contact with the same normalized email already existing returns `409`. ### Request body [#request-body] | Parameter | Type | Required | Description | | ------------ | --------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `email` | string | Yes | Email address. | | `first_name` | string | No | First name. | | `last_name` | string | No | Last name. | | `attributes` | object | No | Attribute values, validated against your registered [contact fields](/docs/contacts/contact-fields). Each key becomes a campaign merge tag (`{{ plan }}`) and a segmentation field. An unregistered key returns `422`. Defaults to `{}`. | | `list_ids` | string\[] | No | Lists to add the new contact to. Unknown ids are reported in `invalid_lists`. | Consent is stamped at creation time (`consent_at = now()`), and the caller IP is recorded on the consent record. Contacts created through this API always have `consent_source: "api"` — it reflects the channel consent came through, so it is set automatically and cannot be overridden in the request. ### Response — `201 Created` [#response--201-created] ```json { "contact": { /* contact object */ }, "added_to_lists": ["ls_newsletter"], "invalid_lists": [] } ``` ```bash curl -X POST https://api.envloped.com/v1/contacts \ -H "Authorization: Bearer en_abc123..." \ -H "Content-Type: application/json" \ -d '{ "email": "jane@example.com", "first_name": "Jane", "attributes": { "plan": "pro" }, "list_ids": ["ls_newsletter"] }' ``` ### Errors [#errors] | Status | `error` | Meaning | | ------ | ------------------- | ----------------------------------------------------------------------------------------------------- | | `400` | `invalid_email` | The email failed validation. | | `409` | `duplicate_contact` | A contact with this normalized email already exists. | | `422` | `unknown_field` | An `attributes` key has no registered contact field. The offending keys are listed in `unknown_keys`. | | `422` | `invalid_attribute` | An `attributes` value does not match its field's type. The offending keys are listed in `invalid`. | ## Create or update contacts in bulk [#create-or-update-contacts-in-bulk] ``` POST /v1/contacts/batch ``` Upsert up to **1000 contacts per request**, keyed on the normalized email. This is the endpoint to sync an approvals table or a members table from your own backend — no CSV, no nightly job. ### Request body [#request-body-1] | Parameter | Type | Required | Description | | ----------------- | ------ | -------- | ----------------------------------------------------- | | `contacts` | array | Yes | Up to 1000 contact objects (below). | | `attributes_mode` | string | No | `merge` (default) or `replace`, applied to every row. | Each entry in `contacts`: | Parameter | Type | Required | Description | | -------------------------- | --------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `email` | string | Yes | Email address. | | `member_status` | string | **Yes** | `approved`, `rejected`, `pending`, or `removed`. Drives [eligibility](#eligibility). Omitting it is an `invalid` row, never an implicit approval — that is the one mistake this endpoint exists to make impossible. | | `first_name` / `last_name` | string | No | | | `attributes` | object | No | Validated against your [contact fields](/docs/contacts/contact-fields). | | `list_ids` | string\[] | No | Lists to add the contact to. | ### Response — always `200` [#response--always-200] Partial success is the contract. The request only fails as a whole if it is malformed. ```json { "processed": 1000, "created": 812, "updated": 176, "rejected_ineligible": 11, "invalid": 1, "results": [ { "email": "jane@example.com", "result": "created", "id": "ct_abc123" }, { "email": "rejected@example.com", "result": "rejected_ineligible" }, { "email": "typo@example.com", "result": "invalid", "reason": "invalid_attributes", "unknown_fields": ["citty"] } ] } ``` | `result` | Meaning | | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `created` | New contact. | | `updated` | Existing contact updated. | | `rejected_ineligible` | `member_status` was not `approved`; nothing was created. | | `invalid` | This row failed validation; see `reason`, plus `unknown_fields` / `invalid_fields` / `invalid_lists` where relevant. **The rest of the batch still applied** — one typo'd attribute key must not discard 999 good rows. | ```bash curl -X POST https://api.envloped.com/v1/contacts/batch \ -H "Authorization: Bearer en_abc123..." \ -H "Content-Type: application/json" \ -H "Idempotency-Key: members-backfill-batch-17" \ -d '{ "attributes_mode": "merge", "contacts": [{ "email": "jane@example.com", "member_status": "approved", "first_name": "Jane", "attributes": { "city": "Singapore", "date_of_birth": "1996-04-12" }, "list_ids": ["ls_members"] }] }' ``` Send an [`Idempotency-Key`](/docs/idempotency) and a replay returns the original receipt verbatim rather than re-running — which is what makes a 35-request backfill reconcilable if one response goes missing. Two behaviours worth knowing: * **Never resurrect.** An existing `unsubscribed` contact stays unsubscribed even if the sync says approved. An unsubscribe is the contact's decision, not your system's. * **Contacts and list membership commit together.** The receipt certifies a committed state, so it cannot report a contact that landed without its lists. ## Eligibility [#eligibility] Eligibility is a separate gate from subscription status and from deliverability suppression. It answers *is this person allowed to be mailed at all* — the case where your own system says someone is not a member, was rejected, or was removed. * A batch row whose `member_status` is not `approved` **never creates a contact** and comes back `rejected_ineligible` with HTTP `200` — so you can fire your entire members table at this endpoint without branching. * An existing contact that flips from approved to `rejected`, `pending` or `removed` is set to `eligibility: "ineligible"` **and** suppressed. It is not deleted: deleting loses the memory of why they must not be mailed. * At send time, an ineligible contact is recorded as skipped with the reason `ineligible` and is never mailed. **No segment, list, or campaign can override this.** Re-approving a contact restores `eligibility` but does not lift the suppression that was written on downgrade — clear it deliberately if that is what you want. ## Polling for changes [#polling-for-changes] To mirror unsubscribes back into your own database, poll with `since`: ```bash curl "https://api.envloped.com/v1/contacts?status=unsubscribed&since=2026-08-09T00:00:00Z" \ -H "Authorization: Bearer en_abc123..." ``` ```json { "contacts": [ /* contact objects */ ], "next_cursor": "eyJ1IjoiMjAyNi0wOC0wOVQxMDozMDowMFoiLCJpIjoiY3RfYWJjIn0" } ``` Follow `next_cursor` until it comes back `null`; `since` only needs to be sent on the first request. This is a **keyset** poll over `(updated_at, id)`, not offset paging — under an active writer, offset paging silently skips rows, which for an unsubscribe feed means missing an opt-out. ## Retrieve a contact [#retrieve-a-contact] ``` GET /v1/contacts/:id ``` Returns the contact plus its live list memberships. ```json { "contact": { /* contact object */ }, "lists": [ { "id": "ls_newsletter", "name": "Newsletter", "unsubscribed_at": null } ] } ``` Returns `404` with `{ "error": "Contact not found" }` if the id is unknown. ## Update a contact [#update-a-contact] ``` PATCH /v1/contacts/:id ``` Updates mutable fields. Only supplied fields change. Setting `status` to `unsubscribed` stamps `unsubscribed_at`; moving to `subscribed`/`pending` clears it. ### Request body [#request-body-2] | Parameter | Type | Description | | ----------------- | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `first_name` | string \| null | New first name (`null` clears it). | | `last_name` | string \| null | New last name (`null` clears it). | | `attributes` | object | Attribute values, validated against your [contact fields](/docs/contacts/contact-fields). | | `attributes_mode` | string | `merge` (default) shallow-merges the supplied keys over the existing object; `replace` swaps it wholesale. Use `merge` when sending partial payloads, or you will wipe fields you did not include. | | `status` | string | New lifecycle status. | An attribute that fails validation returns `422` naming the offending keys. ```bash curl -X PATCH https://api.envloped.com/v1/contacts/ct_abc123 \ -H "Authorization: Bearer en_abc123..." \ -H "Content-Type: application/json" \ -d '{ "status": "unsubscribed" }' ``` Response: `{ "contact": { /* updated contact */ } }`. ## Delete a contact (GDPR erasure) [#delete-a-contact-gdpr-erasure] ``` DELETE /v1/contacts/:id ``` **Hard delete.** The contact row is removed permanently and its list memberships are cascaded away. Campaign history is preserved with the contact reference nulled, so aggregate reports stay intact while the PII is gone. This is the endpoint to satisfy a right-to-erasure request. ```json { "deleted": true, "id": "ct_abc123" } ``` ## Export contacts [#export-contacts] ``` GET /v1/contacts/export ``` Streams the full contact list as a CSV file (`Content-Type: text/csv`) with the columns `email, first_name, last_name, status, consent_source, consent_at, unsubscribed_at, created_at`. The export is keyset-paginated server-side so it stays memory-flat for very large audiences. ```bash curl "https://api.envloped.com/v1/contacts/export" \ -H "Authorization: Bearer en_abc123..." \ -o contacts.csv ``` ## SDKs & CLI [#sdks--cli] The same operations are available in the official SDKs and CLI: ```typescript // JavaScript / TypeScript await client.contacts.create({ email: 'jane@example.com', list_ids: ['ls_newsletter'] }); const { contacts } = await client.contacts.list({ status: 'subscribed' }); ``` ```go // Go client.Contacts.Create(&envloped.CreateContactRequest{Email: "jane@example.com"}) ``` ```bash # CLI envloped contacts create jane@example.com --list ls_newsletter envloped contacts list --status subscribed ```