Send a message
/api/v1/messagesSubmits one message for delivery and returns as soon as it is accepted — delivery
itself happens asynchronously, so a 200 here means "queued", not "in their inbox".
Watch for the outcome through delivery events
or a webhook.
Three things have to be true before a send will work, and they are the cause of almost every first-attempt failure:
- The domain in
fromis verified. See Managing domains and DNS. fromis a verified sender belonging to your organization. An address that merely sits on a verified domain is not enough; it has to exist as a sender.- The recipient is not suppressed. Mailyte refuses sends to addresses that have
bounced, complained or unsubscribed, and it refuses them before your own list
logic gets a say. A suppressed recipient returns
422and records anemail.droppedevent.
Provide content either directly (subject plus html and/or text) or by naming a
template_id. If you do both, the fields you pass win over the template's.
variables are substituted in either case.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
Idempotency-Key | header | string | Send a unique value — a UUID is ideal — to make this request safe to retry. If we have already answered a request with the same key and the same body, you get that exact response back with `Idempotent-Replayed: true` and nothing is sent a second time. Reusing a key with a DIFFERENT body is a 409, because answering the first response to a second message would silently swallow it. Keys are scoped to your organization and honoured for 24 hours. A 5xx does not record a key: we cannot say whether the message left, so your retry genuinely retries. Omit the header and nothing changes. |
Request body
toanyrequiredRecipients. A single address or a list, each either `user@example.com` or `Name <user@example.com>`. At most 50 across to, cc and bcc COMBINED — use /messages/batch beyond that, or when each recipient needs their own values.
ccarray<string>Carbon-copy recipients. Counts toward the same 50.
bccarray<string>Blind-copy recipients. Counts toward the same 50. The addresses reach the envelope and are stripped from the message, so no recipient can see them — and they are NOT echoed on the response, because a receipt is the most likely thing to be forwarded or logged.
fromstringrequiredMust be a verified sender in your organization. The display name comes from the sender record.
reply_tostringWhere replies go instead of `from`. Accepts a display name.
headersobjectCustom headers, at most 25 — for example `In-Reply-To` and `References` to thread a conversation. Headers Mailyte sets (From, To, Subject, Message-ID, DKIM-Signature, List-Unsubscribe, Bcc, X-Mailyte-*) are REFUSED with a 422 rather than silently dropped.
attachmentsarray<object>Files to attach. The whole message may be at most 10 MB, measured AFTER base64 encoding — the encoding inflates by roughly a third, so budget accordingly. Windows executable and script extensions are refused.
namestringrequiredFilename as the recipient sees it. Its extension is checked against the blocked list.
contentstringrequiredThe file, base64 encoded.
content_typestringMIME type. Guessed from the filename when omitted.
content_idstringSet this to embed the file INLINE instead of attaching it, and reference it as `<img src="cid:THE_VALUE">` in your html. Its presence is the only difference between an inline image and a download. The Content-ID header on the wire is generated; your `cid:` reference is rewritten to match, so use the value you set here and nothing else.
subjectstringRequired unless template_id is given.
htmlstringHTML body. Required unless template_id or text is given.
textstringPlain-text body. Always worth sending alongside html.
template_idstringA stored template to render instead of inline content.
variablesobjectSubstituted into subject, html and text. Values are HTML-escaped in html bodies and left raw in subject and text. Write them as `{{ first_name }}`, optionally with `{{ first_name | default('there') }}`. **Mailchimp merge tags work too** — `*|FNAME|*` renders identically, so content migrated from Mailchimp does not have to be rewritten; `FNAME`, `LNAME`, `EMAIL`, `PHONE`, `ADDRESS`, `COMPANY` and `UNSUB` are recognised, and any other `*|TAG|*` matches a variable of that name whatever its capitalisation.
metadataobjectYour own data, returned on every delivery event for this message and NEVER rendered into it. This is what lets you match a webhook to your own record without first storing our message_id against it — put your order id here. Not to be confused with `variables`, which ARE substituted into the subject and bodies. At most 10 fields, keys up to 20 characters and values up to 80.
tagsarray<string>Your own labels, returned on the delivery events for this message. At most 10.
trackingbooleanSet `false` to send this one message with no open pixel and no rewritten links. Use it for password resets, receipts and anything else a person asked for rather than subscribed to — a tracked transactional message looks like a newsletter to a mailbox provider, and gets filed like one. Defaults to true, so nothing changes unless you ask.
streamstringmarketingSend over the marketing egress IP instead of the transactional one. Omit for application mail.
Request
/api/v1/messagescurl -X POST 'https://app.mailyte.com/api/v1/messages' \
-H 'Authorization: Bearer mk_live_YOUR_API_KEY' \
-H 'Content-Type: application/json' \
-d '{
"to": [
"Ada Lovelace <ada@example.com>"
],
"cc": [
"accounts@acme.com"
],
"from": "hello@yourdomain.com",
"reply_to": "support@yourdomain.com",
"subject": "Your receipt from Acme",
"html": "<h1>Thanks, {{first_name}}</h1><p>Your order is on its way.</p>",
"text": "Thanks, {{first_name}}\nYour order is on its way.",
"variables": {
"first_name": "Ada"
},
"tags": [
"receipt"
],
"headers": {
"X-Order-Id": "ord_1234"
},
"metadata": {
"order_id": "ord_1234",
"tier": "pro"
},
"attachments": [
{
"name": "invoice.pdf",
"content": "JVBERi0xLjQKJ...",
"content_type": "application/pdf"
}
]
}'const response = await fetch('https://app.mailyte.com/api/v1/messages', {
method: 'POST',
headers: {
Authorization: 'Bearer mk_live_YOUR_API_KEY',
'Content-Type': 'application/json',
},
body: JSON.stringify({
"to": [
"Ada Lovelace <ada@example.com>"
],
"cc": [
"accounts@acme.com"
],
"from": "hello@yourdomain.com",
"reply_to": "support@yourdomain.com",
"subject": "Your receipt from Acme",
"html": "<h1>Thanks, {{first_name}}</h1><p>Your order is on its way.</p>",
"text": "Thanks, {{first_name}}\nYour order is on its way.",
"variables": {
"first_name": "Ada"
},
"tags": [
"receipt"
],
"headers": {
"X-Order-Id": "ord_1234"
},
"metadata": {
"order_id": "ord_1234",
"tier": "pro"
},
"attachments": [
{
"name": "invoice.pdf",
"content": "JVBERi0xLjQKJ...",
"content_type": "application/pdf"
}
]
}),
});
const { data } = await response.json();import requests
response = requests.post(
"https://app.mailyte.com/api/v1/messages",
headers={"Authorization": "Bearer mk_live_YOUR_API_KEY"},
json={
"to": [
"Ada Lovelace <ada@example.com>"
],
"cc": [
"accounts@acme.com"
],
"from": "hello@yourdomain.com",
"reply_to": "support@yourdomain.com",
"subject": "Your receipt from Acme",
"html": "<h1>Thanks, {{first_name}}</h1><p>Your order is on its way.</p>",
"text": "Thanks, {{first_name}}\nYour order is on its way.",
"variables": {
"first_name": "Ada"
},
"tags": [
"receipt"
],
"headers": {
"X-Order-Id": "ord_1234"
},
"metadata": {
"order_id": "ord_1234",
"tier": "pro"
},
"attachments": [
{
"name": "invoice.pdf",
"content": "JVBERi0xLjQKJ...",
"content_type": "application/pdf"
}
]
},
)
data = response.json()["data"]<?php
$response = Http::withToken('mk_live_YOUR_API_KEY')
->post('https://app.mailyte.com/api/v1/messages', [
'to' => [
'Ada Lovelace <ada@example.com>',
],
'cc' => [
'accounts@acme.com',
],
'from' => 'hello@yourdomain.com',
'reply_to' => 'support@yourdomain.com',
'subject' => 'Your receipt from Acme',
'html' => '<h1>Thanks, {{first_name}}</h1><p>Your order is on its way.</p>',
'text' => 'Thanks, {{first_name}}
Your order is on its way.',
'variables' => [
'first_name' => 'Ada',
],
'tags' => [
'receipt',
],
'headers' => [
'X-Order-Id' => 'ord_1234',
],
'metadata' => [
'order_id' => 'ord_1234',
'tier' => 'pro',
],
'attachments' => [
[
'name' => 'invoice.pdf',
'content' => 'JVBERi0xLjQKJ...',
'content_type' => 'application/pdf',
],
],
]);
$data = $response->json('data');require "net/http"
require "json"
uri = URI("https://app.mailyte.com/api/v1/messages")
request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer mk_live_YOUR_API_KEY"
request["Content-Type"] = "application/json"
request.body = {
"to": [
"Ada Lovelace <ada@example.com>"
],
"cc": [
"accounts@acme.com"
],
"from": "hello@yourdomain.com",
"reply_to": "support@yourdomain.com",
"subject": "Your receipt from Acme",
"html": "<h1>Thanks, {{first_name}}</h1><p>Your order is on its way.</p>",
"text": "Thanks, {{first_name}}\nYour order is on its way.",
"variables": {
"first_name": "Ada"
},
"tags": [
"receipt"
],
"headers": {
"X-Order-Id": "ord_1234"
},
"metadata": {
"order_id": "ord_1234",
"tier": "pro"
},
"attachments": [
{
"name": "invoice.pdf",
"content": "JVBERi0xLjQKJ...",
"content_type": "application/pdf"
}
]
}.to_json
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }Response
The message was accepted for delivery.
dataobjectobjectstringmessageidstringThe opaque handle for this submission: a BARE ULID, no host and no angle brackets. `GET /email-logs/messages/{id}` resolves it immediately -- you do not have to wait for a webhook. It is NOT the Message-ID. NULL WOULD MEAN UNKNOWN and cannot happen on a 2xx: the send mints this before it records anything, so a response that carries a receipt carries a handle. If you are holding null you are not holding an accepted message.
message_idstringThe RFC 5322 Message-ID as it went out on the wire, ANGLE BRACKETS INCLUDED -- `<01JBT8XQ2M...@example.com>`. It is the header your recipient sees and the id `GET /domains/{domain}/messages/{messageId}` takes; percent-encode it in the path. `id` is the bare ULID and the two are not interchangeable: giving `id` to the archive endpoint returns 404, which is precisely what this field used to publish. Null when no sending host could be determined, never a guess.
recipientstringThe PRIMARY addressee, without any display name. Published as `recipient` rather than `to`, matching every event and message row in this section. It stays a single string now that a message may have several recipients, so code reading it does not change type underneath you -- read `recipients` for the whole set. NULL WOULD MEAN UNKNOWN and cannot happen on a 2xx, since `to` is required.
recipientsobjectEveryone the message was addressed to. **`bcc` is deliberately absent**: blind recipients are recorded in your delivery log but never echoed on a receipt, which is the one response most likely to be forwarded, logged or shown to a customer.
toarray<string>As accepted, including any display name.
ccarray<string>Empty when none were given -- NONE, not unknown.
countintegerTo plus Cc. Excludes Bcc, which is not published here; the limit of 50 counts all three together.
reply_tostringThe Reply-To that went out. NULL MEANS NONE was set, in which case replies go to `from`.
attachment_countintegerHow many files were attached. Zero means none were sent, never that we did not look.
fromobjectThe sender. An object here, and everywhere else a sender appears, so one piece of caller code reads all of them.
emailstringThe address this was accepted as, echoed back. NULL WOULD MEAN UNKNOWN and cannot happen on a 2xx -- `from` is a required, validated field on the request and the receipt republishes the resolved sender's own address. It is never "sent from nowhere".
namestringThe display name the message was sent with — the same one that went into the `From:` header, not a lookup done afterwards. NULL MEANS NONE: this sender has no display name set, so the message went out as a bare address. It is not "unknown" and it is not withheld — set a name on the sender and it appears here.
subjectstringThe subject AS RENDERED -- `{{ }}` substituted, the same string that went into the header -- which is why it can differ from what you posted and why it is echoed at all. NULL WOULD MEAN UNKNOWN and cannot happen on a 2xx: the renderer falls back to the empty string, so a subjectless send is published as `""`, never as null. Test for `""`, not for null.
submitted_atstringWhen we ACCEPTED the message, not when it was delivered. A 200 here means queued; the outcome arrives as a delivery event or a webhook. NULL WOULD MEAN UNKNOWN -- we could not read the timestamp off the recorded submission -- and never that the message is not submitted yet. A 2xx has already written the row, and its timestamp column is `NOT NULL DEFAULT current_timestamp()`, so this is not a case to code for.
modestringlive | testWhether a real message was sent. `test` means this request was made with a TEST API KEY: it was validated exactly as a live one would be — the sender had to be verified, the recipients had to not be suppressed, the rate limit applied — and then nothing was handed to the mail server and nothing was recorded. `id`, `message_id` and `submitted_at` are null for that reason: there is no message to look up. Test mode is a property of the key, not of the request, so it cannot be switched on by a stray parameter in production code.
application/json{
"message": "Message submitted successfully",
"code": 200,
"success": true,
"data": {
"message_id": "<01JBT8XQ2M@yourdomain.com>",
"event_id": "01JBT8XQ2M9WYC3K4F6R7S8T9V",
"to": "ada@example.com",
"from": "hello@yourdomain.com",
"subject": "Your receipt from Acme",
"submitted_at": "2026-09-17T10:04:11.000000Z"
}
}Returned inside the standard envelope.
Errors
| Status | When |
|---|---|
401 | The API key is missing, unknown, revoked or expired. All four answer identically, on purpose: distinguishing them would confirm which keys exist. |
403 | The key is valid but may not do this: it lacks the required scope, its IP allowlist does not include you, or this endpoint does not accept API keys. |
404 | No such resource in this organization. |
422 | The request was understood but the values were not acceptable. |
429 | Too many requests, or the organization has spent its sending allowance. `Retry-After` says how long to wait. |
Every status, with what causes it and what to do, is on the error reference.