# Send email with the API

> Single and batch sends, idempotency, attachments, tags, headers, scheduling, streams, suppressions, test mode, reading, sharing and metrics.

Source: https://docs.minket.com/sending/
Updated: 2026-10-29

Every send goes through one pipeline, whichever layer it came in on: an idempotent replay answers first; then, for live mail, the project's kill switch and its daily cap; then the template, the sender rules, the stream, the schedule, suppressions, content checks and attachments; then one transaction stores the email with its idempotency key, and it is queued, simulated (test keys) or scheduled. Sending through a connected mailbox (`via`) has its own [mailboxes guide](https://docs.minket.com/mailboxes/). What happens after the queue (building the message, SES, failover, tracking) has its own [delivery guide](https://docs.minket.com/delivery/).

## The request

`POST /v1/emails` queues one email.

| Field | Type | Notes |
| --- | --- | --- |
| `from` | string | `Name <address>` or a bare address, on a verified domain of the project or a connected mailbox. Optional when a template supplies it. |
| `to`, `cc`, `bcc`, `reply_to` | string or array | Up to 50 addresses each, and 50 recipients in total across `to`, `cc` and `bcc`. |
| `subject` | string | Up to 998 characters, no line breaks. Optional when a template supplies it. |
| `html`, `text` | string | At least one, unless a template is used. A plain-text part is generated when only `html` is given. |
| `template` | object | `{ "id": "<id or alias>", "version": 3, "variables": { … } }`; the published version unless `version` is set ([templates](https://docs.minket.com/templates/)). |
| `headers` | object | See [Custom headers](https://docs.minket.com/sending/#custom-headers). |
| `attachments` | array | Up to 20, see [Attachments](https://docs.minket.com/sending/#attachments). |
| `tags` | array | Up to 50 `{ "name", "value" }` pairs, see [Tags](https://docs.minket.com/sending/#tags). |
| `scheduled_at` | string | See [Scheduling](https://docs.minket.com/sending/#scheduling). |
| `stream` | `transactional` or `marketing` | The `from` domain's stream when left out, see [Streams](https://docs.minket.com/sending/#streams). |
| `topic_id` | UUID | Marketing mail: the topic the email belongs to; its unsubscribe link acts on that topic. |
| `via` | object | `{ "mailbox": "<id>" }` sends through a [connected mailbox](https://docs.minket.com/mailboxes/) instead of a domain. |
| `idempotency_key` | string | Instead of the `Idempotency-Key` header. |
| `tracking` | object | `{ "opens": true, "clicks": false }` overrides the domain's [tracking](https://docs.minket.com/delivery/#open-and-click-tracking) for this email. |

```sh tab=curl
curl https://api.minket.com/v1/emails \
  -H "Authorization: Bearer $MINKET_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-10452-receipt" \
  -d '{
    "from": "Acme <billing@mail.acme.example>",
    "to": "Jane Doe <jane@example.com>",
    "reply_to": "support@acme.example",
    "subject": "Your receipt for order 10452",
    "html": "<p>Thanks, Jane. Your receipt is attached.</p>",
    "tags": [{ "name": "type", "value": "receipt" }],
    "attachments": [{ "id": "019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a5b" }]
  }'
```

```ts tab
const email = await minket.emails.send(
  {
    from: "Acme <billing@mail.acme.example>",
    to: "Jane Doe <jane@example.com>",
    reply_to: "support@acme.example",
    subject: "Your receipt for order 10452",
    html: "<p>Thanks, Jane. Your receipt is attached.</p>",
    tags: [{ name: "type", value: "receipt" }],
    attachments: [{ id: "019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a5b" }],
  },
  { idempotencyKey: "order-10452-receipt" },
);
```

```json
{
  "id": "019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a5c",
  "object": "email",
  "status": "queued",
  "scheduled_at": null,
  "idempotent_replay": false
}
```

Delivery happens afterwards; follow it [by webhook](https://docs.minket.com/webhooks/) or with `GET /v1/emails/{id}/events`.

## Idempotency

A network failure leaves a client unsure whether a send went out. Send the same `Idempotency-Key` with the retry and the API answers with the original result instead of sending again.

- Keys are 1 to 256 characters (`400 invalid_idempotency_key` otherwise) and last 24 hours.
- Same key and same body: the stored response, with `idempotent_replay: true`. No second email.
- Same key and a different body: `422 invalid_idempotent_request`.
- Same key while the first request is still running: `409 concurrent_idempotent_requests`, which is retryable.
- "The same body" means the same fields and values; key order and whitespace do not matter. The header wins over `idempotency_key` in the body.
- Keys belong to the key's project, environment and mode: a test key can never replay a live send.
- A refused request (a validation error, an unverified domain, the daily cap) stores nothing, so the key works again once the cause is fixed. A replay is answered before anything else is checked, so a retry of an accepted send learns it was accepted even while sending is paused.
- The SDK adds a fresh key to every POST that has none. Derive your own from your records (`order-10452-receipt`) when a retry can come from another process.

## Batch

`POST /v1/emails/batch` takes 1 to 100 requests. Each item is validated and queued on its own, and the answer has one result per item, in order:

```json
{
  "object": "batch",
  "data": [
    { "index": 0, "id": "019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a5d", "status": "queued" },
    {
      "index": 1,
      "error": {
        "code": "validation_error",
        "message": "Missing `subject` field.",
        "retryable": false
      }
    }
  ],
  "accepted": 1,
  "rejected": 1
}
```

Give each item its own `idempotency_key`, or send one `Idempotency-Key` header for the whole batch: a repeat with the same body returns the same results and creates nothing. The daily cap counts item by item; items past it fail with `daily_quota_exceeded`.

## Attachments

Three forms, mixed freely within the 20 per email:

```json
{
  "attachments": [
    { "filename": "invoice.pdf", "content": "JVBERi0xLjQK…", "content_type": "application/pdf" },
    { "id": "019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a5b", "filename": "terms.pdf" },
    { "path": "https://files.acme.example/report.pdf", "filename": "report.pdf" }
  ]
}
```

- **Inline:** `filename`, `content` (a base64 string, a JSON-serialized Node Buffer `{ "type": "Buffer", "data": [...] }` or a byte array), optional `content_type`, `content_id` and `disposition` (`attachment` or `inline`). For an inline image set `disposition: "inline"` and a `content_id`, then reference it as `cid:<content_id>` in the HTML. Up to 4 MB each, since the request body stops at 6 MB.
- **By reference:** store a file once with `POST /v1/attachments { "filename", "content", "content_type" }` and send `{ "id" }` in any number of emails, optionally renamed with `filename`. `GET /v1/attachments` lists them, `GET /v1/attachments/{id}` returns one with a one-hour `download_url`, `DELETE /v1/attachments/{id}` removes one, and one unused for 30 days is purged. A stored attachment cannot be deleted while an unsent email uses it (`409 invalid_state`).
- **Remote:** `path` is an https URL fetched at send time from public hosts only: private, loopback, link-local and cloud metadata addresses are refused, also after redirects (at most 3). Remote files are fetched one at a time after the others are counted, and every download of one request, a batch included, shares a 15-second deadline and the message's 40 MB; the first one past either fails its email with `invalid_attachment`.

Stored and remote files take up to 25 MB each, and the message up to 40 MB after encoding. File names are up to 255 characters, without path separators or control characters. The content type comes from `content_type`, the download's own header or the file name, in that order. `GET /v1/emails/{id}/attachments` lists a sent email's attachments with download URLs while its content is kept (30 days).

### Files above 4 MB

```sh
# 1. Ask for a presigned upload (valid 15 minutes).
curl https://api.minket.com/v1/uploads \
  -H "Authorization: Bearer $MINKET_API_KEY" -H "Content-Type: application/json" \
  -d '{ "purpose": "attachment", "filename": "catalog.pdf", "content_type": "application/pdf", "size": 9437184 }'
# 2. PUT the bytes to upload_url, with the same Content-Type.
# 3. GET /v1/uploads/{id} answers "status": "completed" once the stored size matches.
# 4. Store it, then reference the attachment id in any number of emails.
curl https://api.minket.com/v1/attachments \
  -H "Authorization: Bearer $MINKET_API_KEY" -H "Content-Type: application/json" \
  -d '{ "upload_id": "019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a60" }'
```

An upload becomes one attachment once (`consumed`). Sending keys can upload and store attachments but not list or download them. The SDK does all four steps in one call ([Files and lists](https://docs.minket.com/sdks/#files-and-lists)).

## Tags

Tags are `{ "name", "value" }` pairs, up to 50 per email; names and values use ASCII letters, digits, `_` and `-`, up to 256 characters each. They come back on the email, in every event as a flat record (`"tags": { "type": "receipt" }`), and as a filter: `GET /v1/emails?tag=type:receipt`.

## Custom headers

`headers` maps header names to values. Any header the platform does not write itself is allowed; the structural ones are refused with `422 validation_error`: `From`, `Sender`, `To`, `Cc`, `Bcc`, `Subject`, `Date`, `Message-ID`, `Return-Path`, `Received`, `MIME-Version`, the `Content-*` headers, `DKIM-Signature`, the `ARC-*` and `Authentication-Results` headers, `X-SES-*`, `X-Minket-*` and `Feedback-ID`.

- Up to 50 headers and 8 KB in total; names up to 128 characters, values up to 2,000.
- Values cannot contain CR, LF or NUL, so a header can never start another header or the body.
- A `List-Unsubscribe` of your own replaces the platform's pair ([One-click unsubscribe](https://docs.minket.com/preferences/#one-click-unsubscribe)).
- `X-Entity-Ref-ID` with a unique value stops Gmail from threading separate emails.

## Scheduling

Set `scheduled_at` to an ISO 8601 timestamp (`2026-11-02T09:00:00+01:00`; without an offset it is read as UTC) or natural language, at most 30 days ahead. The answer has `status: "scheduled"` and the resolved time, and an `email.scheduled` event is emitted. A time that is not in the future sends now.

Natural language is read in UTC: `now`, `in 30 min`, `in 1 hour`, `in 2 days`, `3 hours from now`, `today at 5pm`, `tomorrow at 9am`, `friday at 3:30pm`, `next monday`, `at 9am` (tomorrow once 9am has passed), `noon`. A bare hour without am, pm or minutes (`at 9`) is refused as ambiguous.

| Action | Request | Answer |
| --- | --- | --- |
| Reschedule | `PATCH /v1/emails/{id}` with `{ "scheduled_at": "in 2 days" }` | `{ "object": "email", "id": "…", "status": "scheduled" }` |
| Cancel | `POST /v1/emails/{id}/cancel` | `{ "object": "email", "id": "…", "status": "canceled" }` and an `email.canceled` event |

Both work while the email is `scheduled`, and answer `409 invalid_state` once it fired or was canceled. A key restricted to a domain moves and cancels only mail from that domain (any other email is `404 not_found`). An email can move to at most 29 days after it was first sent to the API, because its content is kept 30 days (`422 validation_error` past that; moving it earlier always works). A fire that raced a reschedule or a cancel does nothing, so an email is never sent twice, early, or after a cancel.

## Streams

Every domain belongs to one stream, and every email takes its domain's unless `stream` says otherwise.

| Stream | For | Rules |
| --- | --- | --- |
| `transactional` | One-to-one mail the recipient expects: receipts, sign-in links, notifications | Content checks only warn; warnings are recorded on the email (`warnings`). |
| `marketing` | Mail to lists: newsletters, announcements, campaigns | An unsubscribe link in the body (`422 stream_rule_violation`); content checks block (`422 content_blocked`, with the failed checks in `issues`); one recipient per email; never through a mailbox. |

Keep the two on separate subdomains (`mail.` and `news.`, say): complaints and unsubscribes suppress per stream, so a campaign cannot hurt receipts. The platform adds the one-click `List-Unsubscribe` headers to marketing mail ([One-click unsubscribe](https://docs.minket.com/preferences/#one-click-unsubscribe)) and `topic_id` scopes the unsubscribe to one topic; the link in the body is yours.

Marketing mail goes to one reader per email, so its one-click unsubscribe names the person who clicks it: with readers in `bcc`, or several in `to`, an unsubscribe could not tell them apart. More than one recipient across `to`, `cc` and `bcc` is refused with `422 validation_error`. Send one email per reader (a batch of up to 100 works), or a [broadcast](https://docs.minket.com/broadcasts/) to a segment.

The content checks run on every send:

| Check | Transactional | Marketing |
| --- | --- | --- |
| `missing_text_part`: HTML without a plain-text part | warning (a text part is generated) | warning (a text part is generated) |
| `link_domain_mismatch`: links to domains other than the sender's and the platform's | warning | warning |
| `url_shortener`: links through bit.ly, t.co and similar hosts | warning | blocked |
| `html_too_large`: HTML above 100 KB (Gmail clips at 102 KB) | warning | blocked |
| `image_only`: images with almost no text | warning | blocked |
| `missing_unsubscribe`: no unsubscribe link in the body | not checked | refused (`stream_rule_violation`) |

## Suppressions

An address that hard-bounced is suppressed for the whole project; one that complained or unsubscribed, for that stream. A send to a suppressed address is accepted, never handed to a provider, gets status `suppressed` and emits `email.suppressed`. Addresses match in any letter case, with or without a display name. Marketing mail to a contact who unsubscribed from everything is suppressed the same way.

When only some recipients are suppressed, the delivery skips them and the others receive it; the email keeps every address you wrote, and the `email.suppressed` event lists the skipped ones in `to`.

- `GET /v1/suppressions?email=&reason=&stream=` lists them. Reasons: `hard_bounce`, `complaint`, `unsubscribe`, `one_click`, `manual`, `sunset`, `imported`.
- `POST /v1/suppressions` with `{ "email", "stream": null | "marketing", "reason": "manual" | "imported" }` adds one, for example when seeding from another system.
- `DELETE /v1/suppressions/{id}` removes one; remove a hard bounce or a complaint only when you know the address is good.
- Adding and removing need a live key; test keys can list them, and sending keys cannot read them.

## Test mode

A `minket_test_` key goes through the whole pipeline (validation, rendering, storage, logs, events, the webhooks of its environment) and delivers nothing. Test emails are listed in the console's Sandbox and by `GET /v1/emails` with a test key; live keys never see them.

Events play back per recipient, one step every couple of seconds:

| Recipient | Events |
| --- | --- |
| `delivered@minket.test`, and any address outside `minket.test` | `email.sent`, then `email.delivered` |
| `bounced@minket.test` | `email.sent`, then `email.bounced` with a permanent bounce |
| `complained@minket.test` | `email.sent`, `email.delivered`, then `email.complained` |
| `delayed@minket.test` | `email.sent`, `email.delivery_delayed`, then `email.delivered` |

They reach the environment's test endpoints only, and only test keys see them in the feed. From `email.sent` on they carry `data.message_id`, the Message-ID the platform built. They never suppress an address and never count towards the bounce and complaint rates, so a test bounce can be sent again and again.

The sender rule for test keys: `from` must be on a domain of the project, in any state (verification, authentication and sending switched off do not matter, since nothing is delivered), or on `minket.test` itself, which needs no domain at all (`hello@minket.test`). Any other domain is `403 invalid_from_address`, as for live keys. A key restricted to one domain stays restricted. The kill switch and the daily cap apply to live mail only, so test sends keep working while sending is paused.

## Reading emails

`GET /v1/emails/{id}` returns the full email: addresses, `subject`, `headers`, `tags`, `template`, `topic_id`, `via`, `attachments`, `status`, `stream`, `mode`, `source` (`api`, `smtp`, `broadcast`, `automation` or `console`), `provider` (`{ "name": "ses", "region": "us-east-1" }`), `message_id`, `warnings`, `scheduled_at`, `sent_at`, `created_at` and `updated_at`. `html` and `text` are there while the content is kept, 30 days, and null afterwards. `status` follows the email's events ([Email status](https://docs.minket.com/events/#email-status)).

`GET /v1/emails` lists emails without `html`, `text` and `headers`, filtered by `to`, `subject`, `status`, `stream`, `mode`, `tag` (`name:value`), `from_date`, `to_date` and `broadcast_id`. `GET /v1/emails/{id}/events` returns the timeline, a list of `{ "id", "type", "created_at", "data" }` entries.

## Share an email

`POST /v1/emails/{id}/share` returns a link that shows the email to whoever has it, until `expires_at`; `POST /v1/inbound/{id}/share` does the same for a received message.

```json
{ "object": "email", "id": "019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a60", "url": "https://links.minket.com/share/eyJ2IjoxLCJrIjoic2hhcmUiLC4uLn0.c2ln", "expires_at": "2026-10-22T12:00:00.000Z" }
```

- `expires_in` sets how long it works (`10m`, `2 hours`, `1 day`): 48 hours by default and at most. The link is signed, not stored, so it cannot be revoked; it ends when it expires. Keep it as private as the email.
- The page shows the subject, From, To, Cc and date, and the content in a sandboxed frame: no scripts, forms or plugins run, and links open in a new tab. A sent email's images and fonts load over https; a received message loads nothing from the network, so its sender's remote images and tracking pixels never learn who opened it (its inline images still show). Neither page sends a referrer. Once the content is deleted (30 days), the page shows the headers only.
- It needs a full-access key; a test key shares its own test-mode mail. The audit log records each link with its expiry, never the link.

## Metrics

`GET /v1/emails/metrics` counts what happened to the key's mail (its project, environment and mode) over a range: by default today and the six days before it, in UTC.

```sh
curl "https://api.minket.com/v1/emails/metrics?start_date=2026-10-01&end_date=2026-10-07&dimensions=period,domain&metrics=sent,delivered,open_rate&timezone=Europe/Paris" \
  -H "Authorization: Bearer $MINKET_API_KEY"
```

```json
{
  "object": "metrics",
  "start_date": "2026-09-30T22:00:00.000Z",
  "end_date": "2026-10-06T22:00:00.000Z",
  "metrics": ["sent", "delivered", "open_rate"],
  "dimensions": ["period", "domain"],
  "granularity": "daily",
  "totals": { "sent": 1204, "delivered": 1180, "open_rate": 50 },
  "data": [
    { "period": "2026-10-01", "domain_id": "019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a5c", "domain_name": "mail.acme.example", "sent": 172, "delivered": 169, "open_rate": 49.7 }
  ]
}
```

- `start_date` and `end_date` take an ISO 8601 date (the whole day, in `timezone`) or a datetime with its offset; an end after now is now. The answer gives each as the instant it names.
- `metrics`: `received` (emails accepted), `sent`, `delivered`, `delivery_delayed`, `failed`, `suppressed`, `bounced` (with `bounced_transient`, `bounced_permanent` and `bounced_undetermined`), `opened` and `unique_opened`, `clicked` and `unique_clicked`, `complained`, `unsubscribed` (from the email's own links), and the rates in percent with one decimal: `delivery_rate` (delivered per sent), `open_rate` and `click_rate` (unique per delivered), `bounce_rate` (per sent), `complaint_rate` and `unsubscribe_rate` (per delivered). A rate with nothing to divide by is `0`. Opens and clicks count as [tracking](https://docs.minket.com/delivery/#open-and-click-tracking) records them.
- `dimensions`: any of `period`, `domain`, `email` and `broadcast` (`email` and `broadcast` not together). `period` is the period's start in `timezone`: `2026-10-01T13:00` hourly, `2026-10-01` daily, the Monday's date weekly, `2026-10` monthly. A range may span 10,000 periods and a breakdown answer 10,000 rows (past that, shorten the range or filter by id). Rows appear where the requested metrics had something to count, periods in order, then domains and broadcasts by name; mail outside any domain or broadcast is a row without those fields.
- `domain_id`, `email_id` and `broadcast_id` narrow it to up to 100 ids each. Lists take commas or the parameter repeated.

## The sending gate

Live mail is refused when it cannot go out, and each refusal names the cause:

| Cause                                                                           | Error                                    |
| ------------------------------------------------------------------------------- | ---------------------------------------- |
| The `from` domain is not a domain of the project                                | `403 invalid_from_address`               |
| The key is restricted to another domain, or sending is turned off on the domain | `403 invalid_from_address`               |
| The domain is not verified                                                      | `403 domain_not_verified`                |
| SPF, DKIM or DMARC alignment is failing (the DNS checks say so)                 | `403 domain_authentication_failed`       |
| `stream` differs from the domain's stream                                       | `422 stream_rule_violation`              |
| The key's request rate is spent ([rate limits](https://docs.minket.com/limits/#where-limits-apply)) | `429 rate_limit_exceeded`, with `retry-after` |
| The project reached its daily cap on live mail | `429 daily_quota_exceeded` |
| Sending is paused for the project ([when sending pauses](https://docs.minket.com/deliverability/#when-sending-pauses)) | `503 sending_paused`, retryable |
