SMTP and HTTP API
Create API keys and send mail over SMTP or the HTTP API, with request fields, responses, error codes and examples.
You can send through a stream in two ways: over SMTP from any application or mail library, or with the HTTP API. Both use the same API keys, quotas, suppression list and events. Before you start, verify a sending domain and create an API key.
API keys
- Open the stream and go to the API key tab.
- Click Create key, enter a Name such as
production, and confirm. - Copy the key that's shown and click I've saved it. The key is shown only this once. Afterwards the list shows only its Prefix, such as
ytm_aB3dE6gH….
Each key starts with ytm_. A stream can have up to 20 active keys. The list shows when each key was last used. To retire a key, click Revoke: programs using it stop working immediately. A key works only for the stream it belongs to.
Keep API keys on your server. Don't put them in browser code, mobile apps or public repositories. Anyone with the key can send mail as your domain.
SMTP
The Overview tab of the stream shows the SMTP settings:
| Setting | Value |
|---|---|
| Host | The SMTP host shown for the stream, usually smtp.tm.yeapcloud.net |
| Port | 587 with STARTTLS, or 465 with TLS |
| Username | apikey (literally this word) |
| Password | An API key of this stream |
| Category header | X-Yeap-Category |
Use the host shown in the console. Some streams send through a different server group with its own host name.
How SMTP sending works:
- The From header must use an address at a domain verified for the stream.
- Each recipient (To, Cc and Bcc) gets its own copy and its own message ID, and counts as one email.
- To set a category, add a header such as
X-Yeap-Category: password-reset. It's removed before delivery. Allowed characters are letters, digits,.,_and-, up to 64 characters. - A message can be up to 25 MB, with up to 1,000 recipients per SMTP transaction.
- Every message gets an
X-TM-IDheader (the message ID that appears in the sending log and in events) and aFeedback-IDheader for mailbox providers' feedback loops. - On marketing streams, any
List-Unsubscribeheader you set is replaced with Yeap Cloud's one-click unsubscribe headers. SMTP doesn't change the message body, so add your postal address and an unsubscribe link to the content yourself. See Suppressions and unsubscribe. - Open and click tracking are only available through the HTTP API.
Example with Python's standard library:
import smtplib
from email.message import EmailMessage
msg = EmailMessage()
msg["From"] = "Example <noreply@example.com>"
msg["To"] = "user@example.org"
msg["Subject"] = "Reset your password"
msg["X-Yeap-Category"] = "password-reset"
msg.set_content("Click the link to reset your password: https://example.com/reset/abc")
with smtplib.SMTP("smtp.tm.yeapcloud.net", 587) as s:
s.starttls()
s.login("apikey", "ytm_your_api_key")
s.send_message(msg)SMTP response codes
When the server refuses a message, the reply code tells you why. 4xx codes are temporary: retry later. 5xx codes are permanent: fix the cause before sending again.
| Code | Message | Cause and what to do |
|---|---|---|
535 5.7.8 | Authentication failed / API key revoked | Wrong username or key, or the key was revoked. The username must be apikey. |
454 4.7.0 | Temporary authentication failure / Too many authentication failures | The server isn't ready yet, or your IP failed to log in too many times. Wait and retry. |
451 4.7.1 | Stream is paused | The stream was paused automatically. See the reason on the stream and contact support. |
550 5.7.1 | Stream is banned / suspended / terminated | The stream was banned, its subscription is suspended, or it was released. |
550 5.7.1 | From domain … is not verified for this stream | Verify the domain, or check that it's shared with or bound to this stream. |
550 5.7.1 | Sandbox mode: recipient is not a verified address | The stream is in the sandbox. Verify the recipient or send to your own domain. |
550 5.7.1 | Recipient is on a suppression list / All recipients are suppressed | The address is suppressed (bounce, complaint, unsubscribe or manual). |
452 4.7.1 | Hourly sending limit reached, try later | The hourly limit (or the sandbox daily limit) is used up. Retry in the next hour. |
550 5.7.1 | Monthly sending quota exhausted | The monthly volume is used up and overage isn't allowed: on the free plan, or when the overage spending cap is reached. |
452 4.5.3 | Too many recipients | More than 1,000 recipients in one transaction. |
553 5.1.3 | Invalid recipient address | The recipient address is malformed. |
554 5.7.1 | Message rejected: virus detected | The message contained a virus. The stream is banned immediately and all its API keys are revoked. |
554 5.7.1 | Message rejected as spam | The content scored as spam. Repeated rejections pause the stream. |
451 4.3.0 | Content scanner unavailable, try again later | Marketing mail is only accepted after scanning. Retry later. Other 451 4.3.0 replies are temporary server errors: retry. |
554 5.6.0 | Malformed message headers | Fix the message headers. |
HTTP API
| Item | Value |
|---|---|
| Base URL | https://api.tm.yeapcloud.net/v1 (also available as https://api.yeap.cloud/api/tm/v1) |
| Authentication | Authorization: Bearer ytm_your_api_key |
| Format | JSON request and response bodies, Content-Type: application/json |
| Rate limit | 600 requests per minute per API key |
Errors return an HTTP status code and a body like this:
{"error": {"code": "invalid_request", "message": "subject is required (single line)"}}| HTTP status | error.code | Meaning |
|---|---|---|
| 400 | invalid_json, invalid_request | The body isn't a JSON object, or a batch is empty or too large |
| 401 | unauthorized | Missing, malformed, revoked or unknown API key |
| 403 | stream_paused, stream_banned, stream_suspended | The stream can't send |
| 403 | from_domain_unverified, invalid_from | The From domain isn't verified for this stream, or the From address is invalid |
| 403 | postal_address_required | Marketing stream without a postal address. Fill it in on the Marketing settings tab. |
| 403 | virus | An attachment contained a virus. The stream is banned. |
| 404 | not_found | The message doesn't exist in this stream |
| 422 | invalid_request | A field is missing or invalid. message says which one. |
| 429 | rate_limited | More than 600 requests in a minute. Slow down and retry. |
| 503 | no_node | No sending server is available right now. Retry later. |
Send a message: POST /v1/send
| Field | Type | Description |
|---|---|---|
from | string | Required. One address, such as noreply@example.com or Example <noreply@example.com>. The domain must be verified for the stream. |
to | string or array | Required. One address, a comma-separated string, or an array. Array items can also be objects {"email": "...", "name": "..."}. |
cc, bcc | string or array | Optional. Same format as to. Bcc addresses don't appear in the headers. |
reply_to | string or array | Optional. Reply-To address. |
subject | string | Required. One line, up to 998 characters. |
html, text | string | At least one is required. Send both for the best results. |
headers | object | Optional extra headers, such as {"X-Campaign": "spring"}. Standard headers (From, To, Subject, Reply-To, Message-ID, List-Unsubscribe and so on) can't be set here. |
attachments | array | Optional. Items {"filename": "invoice.pdf", "content_base64": "...", "type": "application/pdf"}. At most 10 files and 10 MB in total. |
category | string | Optional. Letters, digits, ., _, -, up to 64 characters. |
tags | array of strings | Optional. Up to 10 tags for your own filtering, each up to 64 characters. |
track_opens, track_clicks | boolean | Optional. Turn open tracking (a tracking pixel) and click tracking (links rewritten through t.tm.yeapcloud.net) on or off for this message. HTML only. If omitted, the stream's default applies. Both are off by default. |
variables | object | Optional. Replaces {{name}} placeholders in subject, html and text. Values are HTML-escaped in html. Unknown placeholders are left as they are. |
A single call can have up to 50 recipients in total across to, cc and bcc. Each recipient gets a separate copy with its own message ID. To send different content to many people, use the batch endpoint.
Example:
curl -X POST https://api.tm.yeapcloud.net/v1/send \
-H "Authorization: Bearer ytm_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"from": "Example <noreply@example.com>",
"to": ["alice@example.org"],
"subject": "Your order {{order_id}} has shipped",
"html": "<p>Hi {{name}}, your order is on its way.</p>",
"text": "Hi {{name}}, your order is on its way.",
"category": "shipping",
"tags": ["order"],
"variables": {"name": "Alice", "order_id": "A-1024"}
}'Response:
{
"message_ids": ["01JAQ3Z9V8K4M2N6P7R5S1T0XW"],
"rejected": []
}message_ids lists the accepted copies. rejected lists recipients that weren't sent, each with a rcpt and a reason. A call can succeed with some or even all recipients rejected, so always check rejected:
reason | Meaning |
|---|---|
invalid_recipient | The address is invalid |
suppressed:<scope>:<reason> | The address is on a suppression list, for example suppressed:global:hard_bounce |
sandbox_recipient_not_verified | The stream is in the sandbox and the recipient isn't allowed |
rate_limited_hourly, rate_limited_sandbox_or_hourly | The hourly limit (or the sandbox daily limit) is used up |
monthly_quota_exceeded | The monthly volume is used up and overage isn't allowed |
enqueue_failed | The message couldn't be queued. It isn't counted against your quota. Retry. |
Python example with requests:
import requests
resp = requests.post(
"https://api.tm.yeapcloud.net/v1/send",
headers={"Authorization": "Bearer ytm_your_api_key"},
json={
"from": "Example <noreply@example.com>",
"to": "alice@example.org",
"subject": "Welcome",
"html": "<p>Welcome aboard!</p>",
"text": "Welcome aboard!",
"category": "welcome",
},
timeout=30,
)
resp.raise_for_status()
result = resp.json()
print(result["message_ids"], result["rejected"])Send in bulk: POST /v1/batch
Send up to 500 messages in one request. Each item has the same fields as /v1/send.
{
"messages": [
{"from": "noreply@example.com", "to": "a@example.org", "subject": "Hi A", "text": "Hello A"},
{"from": "noreply@example.com", "to": "b@example.org", "subject": "Hi B", "text": "Hello B"}
]
}The response has one result per message, in the same order:
{
"results": [
{"index": 0, "message_ids": ["01JAQ..."], "rejected": []},
{"index": 1, "error": {"code": "invalid_request", "message": "subject is required (single line)"}, "rejected": []}
]
}Look up messages: GET /v1/messages
| Query parameter | Description |
|---|---|
status | Filter by status: queued, delivered, deferred, bounced, complained, rejected or failed |
to | Filter by recipient address |
since | Only messages created at or after this time, such as 2026-10-01T00:00:00Z |
limit | Results per page, 1–100 (default 50) |
before | For the next page, pass the next_before value from the previous response |
The response is {"data": [...], "next_before": "..."}. next_before is null on the last page. Messages are kept for 30 days.
GET /v1/messages/{id} returns one message and its events:
{
"id": "01JAQ3Z9V8K4M2N6P7R5S1T0XW",
"stream_id": 12,
"source": "api",
"to": "alice@example.org",
"from": "Example <noreply@example.com>",
"subject": "Welcome",
"category": "welcome",
"tags": [],
"status": "delivered",
"smtp_code": 250,
"smtp_response": "250 2.0.0 OK",
"opened_at": null,
"clicked_at": null,
"unsubscribed_at": null,
"created_at": "2026-10-20 10:15:02",
"updated_at": "2026-10-20 10:15:04",
"events": [
{"type": "delivered", "at": "2026-10-20 10:15:04", "rcpt": "alice@example.org", "smtp_code": 250, "mx": "mx.example.org"}
]
}Suppressions: /v1/suppressions
These endpoints manage the suppression list of the API key's stream.
| Request | Body or query | Response |
|---|---|---|
GET /v1/suppressions | Query email (optional), limit (1–500, default 100), offset | {"data": [{"id", "scope", "email", "reason", "category", "note", "created_at"}]} |
POST /v1/suppressions | {"email": "..."} or {"emails": [...]} (up to 1,000), optional category | {"added": 2, "invalid": []} |
DELETE /v1/suppressions | {"email": "..."}, optional category (body or query) | {"removed": 1} |
Addresses added through the API have the reason manual. Account-wide and global suppressions can't be changed with the API. See Suppressions and unsubscribe.