Emails
Send transactional email and list recent sends. Migrating from Resend? See docs/migration.md.
Send email
POST /emails
Send a transactional email from a verified domain.
Base URL: https://api.groundmail.dev
Authentication
Authorization: Bearer gm_…API keys are created in the dashboard (API Keys). Keys start with gm_.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
from |
string or { email, name? } |
Yes | Must be on a verified domain |
to |
string or string[] |
Yes | Recipient(s) |
subject |
string |
Yes | Subject line |
html |
string |
No* | HTML body (htmlBody / html_body aliases accepted) |
text |
string |
No* | Plain-text body (textBody, text_body, and plain_body aliases accepted) |
reply_to |
string or string[] |
No | Reply-to address(es) (replyTo alias accepted) |
cc |
string or string[] |
No | CC recipient(s) |
bcc |
string or string[] |
No | BCC recipient(s) |
tags |
{ name, value }[] or object |
No | Up to 10 key/value tags stored with the email |
headers |
object |
No | Custom headers; dangerous transport headers are rejected |
attachments |
array |
No | Up to 10 files, 10MB decoded each; executable/script extensions are blocked. Attachment aliases filename, contentType, content, and contentId are also accepted. |
scheduled_at |
string |
No | ISO 8601 timestamp. Creates a scheduled email instead of sending immediately (scheduledAt alias accepted) |
unsubscribe |
boolean |
No | Opt in to managed unsubscribe (see below). Off by default for transactional mail. |
category |
string |
No | Optional override: transactional, product, or newsletter. Inherits from template when sending with a template. |
* At least one of html or text is required.
Managed unsubscribe (opt-in)
Pure transactional mail (password resets, receipts, security alerts) should leave unsubscribe off.
When unsubscribe: true, Groundmail:
- Injects RFC 8058
List-UnsubscribeandList-Unsubscribe-Postheaders (one-click unsubscribe) - Replaces
{{unsubscribe_url}}insubject,html, andtextwith a signed confirmation link - Binds the link to the first
toaddress for that message
When sending with a published template, the template's unsubscribe_enabled flag is used unless you pass unsubscribe explicitly on the send request. Product and newsletter templates auto-enable managed unsubscribe and support category-scoped opt-out — recipients can unsubscribe from that category without blocking transactional mail.
Template categories
Templates have a category that controls preference management:
| Category | Unsubscribe behavior |
|---|---|
transactional |
Default. No category opt-out. Global suppression only. |
product |
Category-scoped opt-out (onboarding tips, upgrade nudges) |
newsletter |
Same mechanism as product, for broadcast-style mail |
Sends to recipients who opted out of a category return 422:
{
"error": {
"message": "One or more recipients opted out of this email category",
"code": "validation_error",
"details": {
"category_unsubscribed": ["user@example.com"],
"category": "product",
"email_id": "msg_…"
}
}
}Global suppressions return the same status with details.suppressed instead.
{
"from": "you@yourdomain.com",
"to": "user@example.com",
"subject": "Product updates",
"html": "<p>Hi there.</p><p><a href=\"{{unsubscribe_url}}\">Unsubscribe</a></p>",
"unsubscribe": true
}Example
{
"from": "you@yourdomain.com",
"to": "user@example.com",
"subject": "Hello from Groundmail",
"html": "<p>It works.</p>"
}With display name:
{
"from": { "email": "you@yourdomain.com", "name": "Your App" },
"to": ["user@example.com", "other@example.com"],
"subject": "Hello",
"html": "<p>Hi</p>",
"text": "Hi",
"reply_to": ["support@yourdomain.com"],
"tags": [{ "name": "order_id", "value": "ord_123" }],
"headers": { "X-Entity-ID": "ord_123" }
}Sandbox self-test
Before your own domain is verified, you can self-test with the shared sandbox domain:
{
"from": "noreply@mail.groundmail.dev",
"to": "owner@example.com",
"subject": "Sandbox check",
"html": "<p>Sandbox works.</p>"
}The sandbox sender is limited to the account email attached to the API key owner.
After your domain is verified, you can also self-test from your verified domain on the free plan — still limited to the account email:
{
"from": "you@yourdomain.com",
"to": "owner@example.com",
"subject": "Verified domain check",
"html": "<p>My domain works.</p>"
}Add a payment method to unlock Free production (send to other recipients on your verified domain — no charge until you upgrade). Upgrade to Pro or Scale for more volume.
Responses
200 — Accepted
{
"id": "msg_abc123…",
"status": "sent"
}Scheduled sends return:
{
"id": "msg_abc123…",
"status": "scheduled"
}id is a stable public message ID (msg_…). Use it to correlate webhook events and dashboard activity.
Idempotency
Optional header on POST /emails:
Idempotency-Key: order-123Keys are scoped per account, retained for 24 hours. Replays return the original response. Reusing a key with a different body returns 409 idempotency_conflict.
await client.emails.send({ …, idempotencyKey: 'order-123' })Retrieve email
GET /emails/{id}
Returns one send by public id (msg_…).
const { email } = await client.emails.get('msg_abc123…')Resend
POST /emails/{id}/resend
Creates a new send from a previously stored email (same from/to/subject/body, tags, and headers). Returns a new msg_… id. Attachment bytes are not stored, so attachments are omitted.
Dashboard: Emails → Resend on any row.
SDK:
await client.emails.resend('msg_abc123…')CLI:
groundmail emails resend --id msg_abc123…Schedule, reschedule, and cancel
Add scheduled_at to POST /emails to store the message and enqueue it for later delivery:
{
"from": "you@yourdomain.com",
"to": "user@example.com",
"subject": "Tomorrow",
"html": "<p>See you tomorrow.</p>",
"scheduled_at": "2026-08-01T12:00:00.000Z"
}Scheduled emails can be updated only while their status is scheduled:
PATCH /emails/msg_abc123…{ "scheduled_at": "2026-08-01T13:00:00.000Z" }Cancel a scheduled email:
{ "cancel": true }SDK:
await client.emails.update('msg_abc123…', { scheduledAt: '2026-08-01T13:00:00.000Z' })
await client.emails.cancel('msg_abc123…')CLI:
groundmail emails cancel --id msg_abc123…Scheduled sends store message bodies so the worker can send them later. Attachments are not supported on scheduled emails in v1 because raw attachment data is not stored.
Batch send
POST /emails/batch
Send up to 100 emails in one request:
{
"emails": [
{
"from": "you@yourdomain.com",
"to": "user@example.com",
"subject": "Hello",
"html": "<p>Hi</p>"
}
]
}The response is index-aligned:
{
"data": [
{ "index": 0, "id": "msg_abc123…", "status": "sent" }
]
}Per-email validation or delivery failures are returned in that item’s error. Attachments are rejected for batch sends in v1.
SDK:
const result = await client.emails.batch([{ from, to, subject, html }])List emails
GET /emails?limit=25&cursor=…
Returns recent sends for the authenticated API key’s account. limit is 1–100 (default 25). Use opaque cursor / next_cursor for pagination.
Responses include rate-limit headers: ratelimit-limit, ratelimit-remaining, ratelimit-reset.
200
{
"emails": [
{
"id": "msg_abc123…",
"from": "you@yourdomain.com",
"to": ["user@example.com"],
"cc": [],
"bcc": [],
"reply_to": [],
"subject": "Hello",
"status": "delivered",
"last_event": "delivered",
"bounce_reason": null,
"tags": [],
"scheduled_at": null,
"cancelled_at": null,
"created_at": "2026-07-25T12:00:00.000Z",
"sent_at": "2026-07-25T12:00:01.000Z",
"delivered_at": "2026-07-25T12:00:05.000Z",
"bounced_at": null
}
],
"has_more": false,
"next_cursor": null
}Status values: queued, scheduled, sent, delivered, bounced, failed, cancelled.
SDK
const { emails, next_cursor } = await client.emails.list({ limit: 10 })Error format
All errors return:
{
"error": {
"message": "Human-readable message",
"code": "validation_error",
"details": {}
}
}| Status | When |
|---|---|
| 400 | Invalid JSON, missing fields, invalid from |
| 401 | Missing/invalid Bearer token or API key |
| 403 | from domain not verified, sandbox/verified-domain sender used for recipients outside the account email, or send to other recipients without Free production unlocked (payment method on file) |
| 409 | Idempotency key conflict |
| 429 | Free plan send limit (plan_limit) |
| 502 | Upstream mail delivery error |
| 503 | Mail delivery temporarily unavailable |
Attachment limits
- Maximum 10 attachments per non-batch, non-scheduled send
- Maximum 10MB decoded size per attachment
- Blocked filename extensions:
.exe,.bat,.cmd,.com,.scr,.js,.vbs,.dll
Examples
curl
curl -X POST https://api.groundmail.dev/emails \
-H "Authorization: Bearer $GROUNDMAIL_API_KEY" \
-H "Content-Type: application/json" \
-d '{"from":"you@yourdomain.com","to":"user@example.com","subject":"Hello","html":"<p>Hi</p>"}'Node.js
import { Groundmail } from 'groundmail'
const client = new Groundmail(process.env.GROUNDMAIL_API_KEY!)
await client.emails.send({
from: 'you@yourdomain.com',
to: 'user@example.com',
subject: 'Hello',
html: '<p>Hi</p>',
})Throws GroundmailError on failure (error.status, error.message, error.details).
CLI
groundmail emails send \
--from you@yourdomain.com \
--to user@example.com \
--subject "Hello" \
--html "<p>Hi</p>" \
--tag order_id=ord_123 \
--schedule 2026-08-01T12:00:00.000ZRequires GROUNDMAIL_API_KEY or --api-key.
OpenAPI
Machine-readable spec: openapi/groundmail.yaml