Send email with the API
Single and batch sends, idempotency, attachments, tags, headers, scheduling, streams, suppressions, test mode, reading, sharing and metrics.
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. What happens after the queue (building the message, SES, failover, tracking) has its own delivery guide.
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>",; the published version unless version is set (templates). |
headers |
object | See Custom headers. |
attachments |
array | Up to 20, see Attachments. |
tags |
array | Up to 50 { "name", "value" } pairs, see Tags. |
scheduled_at |
string | See Scheduling. |
stream |
transactional or marketing |
The from domain's stream when left out, see 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 instead of a domain. |
idempotency_key |
string | Instead of the Idempotency-Key header. |
tracking |
object | { "opens": true, overrides the domain's tracking for this email. |
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" }]
}'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" },
);{
"id": "019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a5c",
"object": "email",
"status": "queued",
"scheduled_at": null,
"idempotent_replay": false
}Delivery happens afterwards; follow it by webhook 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_keyotherwise) 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_keyin 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:
{
"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:
{
"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), optionalcontent_type,content_idanddisposition(attachmentorinline). For an inline image setdisposition: "inline"and acontent_id, then reference it ascid:<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 withfilename.GET /v1/attachmentslists them,GET /v1/attachments/{id}returns one with a one-hourdownload_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:
pathis 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 withinvalid_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
# 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).
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-Unsubscribeof your own replaces the platform's pair (One-click unsubscribe). X-Entity-Ref-IDwith 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", |
| Cancel | POST / |
{ "object": "email", 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) 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 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/suppressionswith{ "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).
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.
{ "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_insets 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.
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"{
"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_dateandend_datetake an ISO 8601 date (the whole day, intimezone) 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(withbounced_transient,bounced_permanentandbounced_undetermined),openedandunique_opened,clickedandunique_clicked,complained,unsubscribed(from the email's own links), and the rates in percent with one decimal:delivery_rate(delivered per sent),open_rateandclick_rate(unique per delivered),bounce_rate(per sent),complaint_rateandunsubscribe_rate(per delivered). A rate with nothing to divide by is0. Opens and clicks count as tracking records them.dimensions: any ofperiod,domain,emailandbroadcast(emailandbroadcastnot together).periodis the period's start intimezone:2026-10-01T13:00hourly,2026-10-01daily, the Monday's date weekly,2026-10monthly. 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_idandbroadcast_idnarrow 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) | 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) | 503 sending_paused, retryable |