---
title: "Send one email"
method: POST
path: "/emails"
tags: ["Email"]
---

# Send one email

`POST /emails`

## Request

- Base URL: `https://api.hypamoon.com/api/v1`
- URL: `https://api.hypamoon.com/api/v1/emails`
- Auth: one of:
  - HTTP bearer
  - OAuth 2 (authorization code, authorization URL `https://app.hypamoon.com/oauth/authorize`, token URL `https://api.hypamoon.com/oauth/token`, scopes: `messages:write`)

Sends one email from an address on a verified sending domain of this project, like Resend: to is one address or a list, with optional cc and bcc (at most 50 recipients together). Everyone in to and cc sees the others; use bcc or POST /emails/batch when people must not see each other. Every recipient must appear in the project (a form submission, a document, a chat conversation, a user of one of your apps) or be confirmed by the owner. category other (the default) adds an unsubscribe link and header plus the project's postal address (from PUT /project/company, required by the US CAN-SPAM Act; without it the email is refused with company_details_missing) and goes to exactly one recipient; several recipients need category transactional (a reply to something they all did). attachments: at most 10 files, 10 MB together, each with a filename and one of content (base64), media_id (a file from Media) or path (an https address on one of this project's sites). Allowed: PDF, PNG, JPG, GIF, WebP, and ICS, TXT or CSV as UTF-8 text; the type is checked on the file's first bytes. Keep the request under about 4 MB; use media_id or path for larger files. Every email passes a content check (no phishing, no scripts or forms, no links that hide where they go). Limits count every recipient: a daily limit per project, 200 recipients and 20 calls per minute, and a cap on new recipients a day that only appear in documents your app wrote (25 to start, growing to 100 and 500 as emails are delivered without bounces or complaints; see mail.new_recipients in GET /emails). Each recipient also counts as one email for the bundle. Sending pauses automatically when bounces exceed 5% or spam complaints exceed 0.1% over the last 200 emails. With a test key nothing is sent: after all the same checks, the email gets status simulated. An Idempotency-Key (header or idempotency_key) returns the same email instead of a second one. template_id with data sends a template (POST /email-templates) instead of subject, html and text. send_at sends it later (at most 30 days ahead): the answer is then a scheduled email (202), which POST /scheduled-emails/{id}/cancel stops.

## Headers

- `Idempotency-Key` string

## Request body

- object
  - `from` string, required: The sender: an address on a verified sending domain of this project, optionally with a name, like "Acme Plumbing <hello@acme.com>".
  - `to` union, required: One address or a list (at most 50 recipients in to, cc and bcc together). Everyone in to and cc sees the others, as in any email; use bcc, or a batch with one email per person, when they must not. Every address must appear in this project (a form submission, a document, a chat conversation, a user of one of your apps) or be confirmed by the owner.
    - string
    - string[]
  - `cc` string[]: Optional. Copy recipients; they see the to and cc addresses. The same rules as to.
  - `bcc` string[]: Optional. Hidden recipients; nobody sees them. The same rules as to.
  - `subject` string: The subject line. Required without template_id; with template_id it comes from the template.
  - `text` string: The plain text body. Send text, html or both.
  - `html` string: The HTML body. No scripts, no forms; links must show where they go.
  - `reply_to` string: Optional. Where replies go, like the user's own mailbox.
  - `attachments` object[]: Optional. At most 10 files, 10 MB together: PDF, PNG, JPG, GIF, WebP, and ICS, TXT or CSV as UTF-8 text. Never executables, HTML, SVG or archives.
    - `filename` string: The file name with its extension, like invoice.pdf. Required with content and path; with media_id it defaults to the name in Media. Allowed: pdf, png, jpg, jpeg, gif, webp, ics, txt, csv.
    - `content` string: The file as base64. Counts toward the request size (keep the whole request under about 4 MB); for a larger file use media_id or path.
    - `media_id` string, uuid: A file from this project's Media library (its id from list_media).
    - `path` string: The https address of a file on one of this project's sites, like https://acme.hypamoon.app/menu.pdf. Other hosts are refused.
    - `content_type` string: Optional and only informative: the type is always taken from the file itself.
  - `category` 'transactional' | 'other': transactional: a reply to something the recipients did (an order, a booking, a form they sent). other (default): anything else; it gets an unsubscribe link with the project's postal address (needs a business address in the company details) and a List-Unsubscribe header, is not sent to people who unsubscribed, and goes to exactly one recipient (use a batch for more people).
  - `in_reply_to` string, uuid: The id of the form submission (a document from a public form) or the chat conversation this email answers. Required when the recipient appears in the project only through a form or a chat; the address must be in that submission or conversation.
  - `check_recipients` boolean: Optional, default true: before the first email to an address, check that its domain exists and accepts email, and refuse likely typos (like gmial.com) and, for category other, throwaway addresses. false skips those checks; an address that bounced before is always refused.
  - `idempotency_key` string: Optional. The same key returns the same email instead of sending a second one (also as the Idempotency-Key header).
  - `template_id` string, uuid: Optional. A template (create_email_template): the subject, html and text come from it, filled with data. Leave out subject, html and text.
  - `data` object: With template_id: the values for its variables, like { "name": "Sam" }.
  - `send_at` string: Optional. Send later, like 2026-11-02T09:00:00Z, at most 30 days ahead: the answer is a scheduled email (202) that cancel_scheduled_email can stop. All checks except the form run when it is sent.

## Response `200`

The same Idempotency-Key as before: the original email, and nothing is sent again.

- object
  - `id` string, uuid, required: The id of the email. sendEmail and sendEmailBatch in an app return it; getEmail(id) reads it back.
  - `app_id` string, uuid, nullable, required: The app that sent it with env.HYPAMOON.sendEmail; null when it was sent with the API, by an agent or from the portal.
  - `from` string, required
  - `to` string, required: The first address in to. All recipients are in recipients.
  - `recipients` object, required: Everyone this email went to. After 90 days the addresses are masked, like s***@example.org.
    - `to` string[], required
    - `cc` string[], required
    - `bcc` string[], required
  - `recipient_count` integer, required: to, cc and bcc together; each counts toward the limits and the bundle.
  - `attachments` object[], required: Name, type and size of each attachment. The content is never stored.
    - `filename` string, required
    - `content_type` string, required
    - `size` integer, required: In bytes.
  - `reply_to` string, nullable, required
  - `subject` string, required
  - `category` 'transactional' | 'other', required
  - `status` 'queued' | 'sent' | 'delivered' | 'bounced' | 'complained' | 'failed' | 'simulated', required: queued, sent (accepted for delivery), delivered, bounced, complained (marked as spam), failed, or simulated (test mode).
  - `recipient_source` 'confirmed' | 'member' | 'form' | 'document' | 'conversation' | 'app_user' | 'buyer' | 'pass_holder' | 'null', nullable, required: Where the recipient appears in the project.
  - `test` boolean, required
  - `error` string, nullable, required
  - `reason` string, nullable, required: Why the email bounced, was marked as spam, is delayed or was not sent, in plain words. null otherwise.
  - `recipient_status` object[], required: The status per recipient. With several recipients one can bounce while the others are delivered.
    - `email` string, required: The address. Masked after 90 days, like s***@example.org.
    - `field` 'to' | 'cc' | 'bcc', required
    - `status` 'queued' | 'sent' | 'delivered' | 'bounced' | 'complained' | 'failed' | 'simulated', required
    - `reason` string, nullable, required: Why it bounced, was marked as spam, is delayed or was not sent.
    - `updated_at` string, nullable, required: When this recipient's status last changed.
  - `timeline` object[], required: Every step, oldest first: queued, then sent (or simulated, or failed), then what the recipient's mail server reported.
    - `status` 'queued' | 'sent' | 'delivered' | 'delayed' | 'bounced' | 'complained' | 'failed' | 'simulated', required: queued, sent, delivered, delayed (the recipient's server has not accepted it yet; retried automatically), bounced, complained, failed or simulated.
    - `at` string, required: When it happened (ISO 8601).
    - `recipient` string, nullable, required: The recipient this step is about; null for every recipient.
    - `reason` string, nullable, required: A short reason in plain words, for delayed, bounced, complained and failed.
  - `created_at` string, required
  - `sent_at` string, nullable, required
  - `delivered_at` string, nullable, required
  - `bounced_at` string, nullable, required
  - `complained_at` string, nullable, required
  - `sender` 'hypamoon_fallback': Only when the project has no verified sending domain of its own: the email went from Hypamoon's address ("<app or project> via Hypamoon"), in a neutral layout without the project's images or design. Tell the user so.
  - `next_step` string: With sender hypamoon_fallback: how to send from the project's own address with its own design.

## Other responses

- `201`: Accepted for delivery (or simulated with a test key).
- `202`: With send_at: the scheduled email. get_scheduled_email shows it; once sent, email_id is the email.
- `400`: Bad Request: invalid_json, idempotency_key_invalid.
- `401`: Unauthorized: unauthorized, missing_api_key, invalid_api_key, api_key_revoked, project_deleted.
- `402`: Payment Required: insufficient_balance.
- `403`: Forbidden: project_not_enabled, key_mode_mismatch, missing_scope, verification_required, mail_not_enabled, email_paused, recipient_not_allowed.
- `404`: Not Found: not_found.
- `409`: Conflict: idempotency_key_reused, idempotency_request_in_progress, email_domain_not_verified.
- `413`: Error: attachment_too_large.
- `422`: Unprocessable Content: validation_failed, email_address_invalid, company_details_missing, email_blocked, attachment_not_allowed.
- `429`: Too Many Requests: rate_limited, email_limit_reached.
- `500`: Internal Server Error: internal_error.
- `502`: Bad Gateway: upstream_error.
- `503`: Service Unavailable: service_unavailable.

---

[API](https://skmtc.dev/hypamoon/apis/hypamoon-api.md) · [All operations](https://skmtc.dev/hypamoon/apis/hypamoon-api/llms.txt) · [OpenAPI document](https://skmtc.dev/hypamoon/apis/hypamoon-api/revisions/9f0b90004c06?raw)
