Overview
The API lives at https://api.ownmailserver.com. It takes JSON request bodies, returns JSON, and uses ordinary HTTP status codes. The request and response shapes follow Resend's API, so Resend's official Node.js and Python SDKs work with us unchanged. You only change the base URL and the API key. See Moving from Resend.
| Base URL | https://api.ownmailserver.com |
| Authentication | Authorization: Bearer <API key> |
| Request limit | 10 requests per second per account (ask us to raise it) |
| Largest email | 10 MB |
| Recipients per email | 50 in total across to, cc and bcc |
| SMTP | smtp.ownmailserver.com, port 587, STARTTLS |
What you can do with the API:
- Send email, now, later or up to 100 at a time, list what you've sent, and look up what happened to each message:
POST /emails,POST /emails/batch,GET /emails,GET /emails/{id}. - Add and verify sending domains:
/domains. - Manage your suppression list:
/suppressions. - Track opens and clicks, switched on per domain:
PATCH /domains/{id}. - Get webhooks for deliveries, bounces, complaints and unsubscribes:
/webhooks.
Quick start
- Sign up at app.ownmailserver.com. We review every account, usually within one business day.
- Add your sending domain and publish the four DNS records the dashboard shows you. The API can do this too, see Domains.
- Create an API key under API keys & SMTP. The key is shown only once, so copy it straight away.
- Send your first email:
curl -X POST https://api.ownmailserver.com/emails \
-H "Authorization: Bearer $OMS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "Acme <hello@acme.com>",
"to": "jane@example.com",
"subject": "Your receipt",
"html": "<p>Thanks for your order.</p>",
"text": "Thanks for your order."
}'
{ "id": "5f0c1a52-8f3e-4c1b-9a0e-2a8f1d6b7c34" }
The same request in Node.js 18 or later, with no extra packages:
const res = await fetch("https://api.ownmailserver.com/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.OMS_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
from: "Acme <hello@acme.com>",
to: ["jane@example.com"],
subject: "Your receipt",
html: "<p>Thanks for your order.</p>",
}),
});
const body = await res.json();
if (!res.ok) throw new Error(`${body.name}: ${body.message}`);
console.log(body.id);
And in Python, using requests:
import os, requests
r = requests.post(
"https://api.ownmailserver.com/emails",
headers={"Authorization": f"Bearer {os.environ['OMS_API_KEY']}"},
json={
"from": "Acme <hello@acme.com>",
"to": ["jane@example.com"],
"subject": "Your receipt",
"text": "Thanks for your order.",
},
timeout=30,
)
r.raise_for_status()
print(r.json()["id"])
Authentication
Send your API key as a bearer token with every request:
Authorization: Bearer mp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Keys start with mp_. We store only a hash of each key, so we can't show it to you again later. If you lose one, create a new key and revoke the old one in the dashboard. A revoked key stops working straight away.
A key gives full access to its account: it can send email and manage domains, suppressions and webhooks. Keep keys on your server and never put them in browser or mobile app code.
Requests fail with 403 while your account is waiting for approval (account_pending) and while sending is paused (account_paused or account_suspended).
Send an email
POST /emails
Puts one email in the send queue. A successful response means we accepted the email, not that it has been delivered yet. To find out what happened next, use GET /emails/{id} or webhooks.
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
from |
string | yes | "hello@acme.com" or "Acme <hello@acme.com>". The domain must be verified on your account. |
to |
string or string[] | yes | One address or a list of addresses. |
subject |
string | yes | Up to 998 characters. |
html |
string | one of html or text |
The HTML body. |
text |
string | one of html or text |
The plain-text body. Send both if you can, because some receiving servers prefer mail that has a text part. |
cc |
string or string[] | no | |
bcc |
string or string[] | no | Never shown to the other recipients. |
reply_to |
string or string[] | no | |
headers |
object | no | Extra headers, as {"X-Entity-Ref-ID": "123"}. |
tags |
object[] | no | [{"name": "category", "value": "receipt"}]. Names and values can be up to 256 characters. Tags come back in webhook payloads. |
attachments |
object[] | no | Up to 20 files. See Attachments. |
scheduled_at |
string | no | Send later instead of now. See Scheduled sending. |
You can't set these headers yourself, because we set them: From, To, Cc, Bcc, Subject, Message-ID, Return-Path, DKIM-Signature, Date and Sender. Use the body fields instead.
Attachments
Each attachment is an object with these fields:
| Field | Required | Notes |
|---|---|---|
filename |
yes | Shown to the recipient. It can't contain slashes or line breaks. |
content |
yes | The file, base64-encoded. Line breaks in the base64 are fine. |
content_type |
no | For example application/pdf. If you leave it out, we work it out from the filename. |
content_id |
no | Makes the file an inline image. Refer to it in your HTML as <img src="cid:logo">. |
"attachments": [
{ "filename": "invoice-1042.pdf", "content": "JVBERi0xLjQKJ…" },
{ "filename": "logo.png", "content": "iVBORw0KGgo…", "content_id": "logo" }
]
The whole request, attachments included, must be under 10 MB. Base64 makes files about a third larger, so the files themselves can add up to roughly 7 MB. We don't fetch files from a URL, so Resend's path field isn't supported. We refuse executable files such as .exe, .bat, .js and .iso, because Gmail and Outlook reject email that carries them.
We delete the file contents as soon as our mail server has accepted the email. GET /emails/{id} still lists each file's name and size.
Response
{ "id": "5f0c1a52-8f3e-4c1b-9a0e-2a8f1d6b7c34" }
If any recipient is on your suppression list, we drop that recipient, send to the rest, and tell you who was dropped:
{
"id": "5f0c1a52-8f3e-4c1b-9a0e-2a8f1d6b7c34",
"suppressed": [{ "email": "old@example.com", "reason": "hard_bounce" }]
}
If every recipient is suppressed, the email is stored with status suppressed and nothing is sent.
Idempotency keys
Networks fail, so clients retry. To make a retry safe, send an Idempotency-Key header with any unique value up to 256 characters, such as your order id:
curl -X POST https://api.ownmailserver.com/emails \
-H "Authorization: Bearer $OMS_API_KEY" \
-H "Idempotency-Key: receipt-order-1042" \
-H "Content-Type: application/json" \
-d '{ "from": "hello@acme.com", "to": "jane@example.com", "subject": "Your receipt", "text": "Thanks." }'
If you repeat the same request with the same key within 24 hours, you get the first response back, with an idempotent-replayed: true header, and nothing is sent again. Keys belong to your account, so other accounts can't clash with yours. POST /emails/batch accepts them too.
- Same key, different request body:
409 invalid_idempotent_request. - Same key while the first request is still running:
409 concurrent_idempotent_requests. Wait a moment and retry. - The first request failed (any error): we don't keep the key, so you can fix the request and send it again with the same key.
Limits during warm-up
A new account sends from a fresh dedicated IP, so we cap how many recipients it can reach in any rolling 24 hours. We raise the cap as the IP warms up (see our IP warm-up guide). Going over the cap returns 429 daily_quota_exceeded. Suppressed recipients don't count toward the cap.
Unsubscribe headers
Gmail and Yahoo require marketing mail to offer one-click unsubscribe (RFC 8058). If an email looks like marketing (its content mentions unsubscribing or opting out) and has a single recipient, we add List-Unsubscribe and List-Unsubscribe-Post headers for you. When someone clicks unsubscribe, we add them to your suppression list with reason list_unsubscribe and send an email.unsubscribed webhook. They will still get your transactional mail, such as receipts and password resets.
You can control this for a single email with an extra header:
"headers": { "X-OMS-Unsubscribe": "off" }
on treats the email as marketing and off treats it as transactional. We read this header and remove it before sending. If you set your own List-Unsubscribe header, we leave it as it is.
Send a batch
POST /emails/batch
Sends up to 100 emails in one request. The body is an array of emails in the same shape as POST /emails:
curl -X POST https://api.ownmailserver.com/emails/batch \
-H "Authorization: Bearer $OMS_API_KEY" \
-H "Content-Type: application/json" \
-d '[
{ "from": "hello@acme.com", "to": "jane@example.com", "subject": "Your receipt", "text": "Thanks, Jane." },
{ "from": "hello@acme.com", "to": "sam@example.com", "subject": "Your receipt", "text": "Thanks, Sam." }
]'
{ "data": [{ "id": "5f0c1a52-…" }, { "id": "9b2e7d10-…" }] }
The ids are in the same order as your emails. A batch is all or nothing: we check every email, and the warm-up cap for the whole batch, before we queue any of them. If one email is invalid, nothing is sent, and the error names the bad email by its position, for example [1].to: invalid address. As with Resend, a batch can't include attachments or scheduled_at. Send those emails one at a time.
Scheduled sending
Add scheduled_at to POST /emails to send the email later, up to 30 days ahead. Use ISO 8601 with a time zone, so the time can't be misread:
{
"from": "hello@acme.com",
"to": "jane@example.com",
"subject": "Your appointment is tomorrow",
"text": "See you at 10am.",
"scheduled_at": "2026-11-02T09:00:00-08:00"
}
The email has status scheduled until then. Just before it goes out, we check your suppression list again. Anyone who bounced, complained or unsubscribed while the email was waiting is dropped and recorded as a suppressed event. Scheduled emails count toward the warm-up cap on the day you schedule them.
While an email is still scheduled, you can change it:
| Request | What it does |
|---|---|
PATCH /emails/{id} with {"scheduled_at": "…"} |
Moves it to a new time. |
POST /emails/{id}/cancel |
Cancels it. Its status becomes canceled and it's never sent. |
Once the email is being sent, both return 409 not_scheduled.
List emails
GET /emails
Lists the emails you've sent, newest first. Each email shows its status and latest event, but not its body or attachments. Paging works the same way as Resend's:
curl "https://api.ownmailserver.com/emails?limit=50&last_event=bounced" \
-H "Authorization: Bearer $OMS_API_KEY"
{
"object": "list",
"has_more": true,
"data": [
{
"object": "email",
"id": "5f0c1a52-8f3e-4c1b-9a0e-2a8f1d6b7c34",
"from": "Acme <hello@acme.com>",
"to": ["old@example.com"],
"cc": [],
"bcc": [],
"subject": "Your receipt",
"status": "sent",
"tags": [{ "name": "category", "value": "receipt" }],
"created_at": "2026-10-27T14:02:11.418Z",
"sent_at": "2026-10-27T14:02:12.006Z",
"last_event": "bounced",
"last_event_at": "2026-10-27T14:02:13.551Z"
}
]
}
| Parameter | Notes |
|---|---|
limit |
1 to 100. The default is 20. |
after |
An email id. Returns the emails older than it. To get the next page, pass the last id of the current page. |
before |
An email id. Returns the emails newer than it, which takes you back a page. You can't use it together with after. |
recipient |
A full address, or @example.com for every address at a domain. Matches to, cc and bcc, ignoring upper and lower case. |
subject |
Text the subject contains, ignoring upper and lower case. |
status |
scheduled, queued, sending, sent, failed, canceled or suppressed. |
last_event |
The type of the latest event, for example bounced, complained or delivered. |
since, until |
ISO 8601 dates or times, such as 2026-10-01 or 2026-10-27T14:00:00Z. since is inclusive and until is exclusive. |
When has_more is true, there are more emails after this page. Filters apply to every page, so send the same filters with each request.
Retrieve an email
GET /emails/{id}
Returns one email you sent, with every event recorded for it so far.
curl https://api.ownmailserver.com/emails/5f0c1a52-8f3e-4c1b-9a0e-2a8f1d6b7c34 \
-H "Authorization: Bearer $OMS_API_KEY"
{
"object": "email",
"id": "5f0c1a52-8f3e-4c1b-9a0e-2a8f1d6b7c34",
"from": "Acme <hello@acme.com>",
"to": ["jane@example.com"],
"cc": [],
"bcc": [],
"reply_to": [],
"subject": "Your receipt",
"attachments": [{ "filename": "invoice-1042.pdf", "size": 48213 }],
"status": "sent",
"created_at": "2026-10-27T14:02:11.418Z",
"sent_at": "2026-10-27T14:02:12.006Z",
"last_event": "delivered",
"events": [
{ "type": "queued", "recipient": null, "created_at": "2026-10-27T14:02:11.420Z", "detail": { "source": "api" } },
{ "type": "sent", "recipient": null, "created_at": "2026-10-27T14:02:12.006Z", "detail": { "mta_queue_id": "4Zx1Lq0Bv3z9" } },
{ "type": "delivered", "recipient": "jane@example.com", "created_at": "2026-10-27T14:02:13.551Z",
"detail": { "dsn": "2.0.0", "relay": "gmail-smtp-in.l.google.com", "response": "250 2.0.0 OK" } }
]
}
The status field shows where the email is in our queue:
| Status | Meaning |
|---|---|
scheduled |
Waiting for its scheduled_at time. |
queued |
Accepted and waiting to be sent. |
sending |
Being handed to our mail server right now. |
sent |
Our mail server has it and is delivering it. The result for each recipient appears in events. |
failed |
We couldn't hand it over even after retrying. The reason is in error. |
suppressed |
Every recipient was suppressed, so nothing was sent. |
canceled |
You canceled it before its scheduled time. |
Event types: scheduled, canceled, queued, sent, delivered, deferred (the receiving server asked us to try again later, and we will), bounced, complained, unsubscribed, opened, clicked, suppressed and failed. Delivery events are per recipient.
Domains
You can only send from domains that are on your account and verified. When you add a domain, we create a 2048-bit DKIM key for it and return the four DNS records to publish:
| Record | Type | Name | Why |
|---|---|---|---|
| DKIM | TXT | mp1._domainkey |
Signs your mail as coming from your domain. |
| Return path | MX | send |
Brings bounces back to us, so we can suppress dead addresses. |
| SPF | TXT | send |
Allows our servers to send for the return-path domain. |
| DMARC | TXT | _dmarc |
Gmail and Yahoo require it from bulk senders. p=none is enough to start. |
Record names are relative to your domain, which is how most DNS dashboards expect them. Each record also has a full_name field with the complete name. If you already have a DMARC record, keep it: any valid policy counts. Our guides cover DKIM, SPF and DMARC in more detail.
Add a domain
POST /domains with {"name": "acme.com"} returns 201 with the domain object:
{
"object": "domain",
"id": "0b8e4f7a-3c2d-4e1f-8a9b-6c5d4e3f2a1b",
"name": "acme.com",
"status": "pending",
"created_at": "2026-10-27T13:40:02.117Z",
"records": [
{ "record": "DKIM", "type": "TXT", "name": "mp1._domainkey", "full_name": "mp1._domainkey.acme.com",
"value": "v=DKIM1; k=rsa; p=MIIBIjANBgkqh…", "status": "not_started" },
{ "record": "Return path", "type": "MX", "name": "send", "full_name": "send.acme.com",
"value": "feedback.ownmailserver.com", "priority": 10, "status": "not_started" },
{ "record": "SPF", "type": "TXT", "name": "send", "full_name": "send.acme.com",
"value": "v=spf1 include:_spf.ownmailserver.com -all", "status": "not_started" },
{ "record": "DMARC", "type": "TXT", "name": "_dmarc", "full_name": "_dmarc.acme.com",
"value": "v=DMARC1; p=none;", "status": "not_started" }
]
}
You can't add free mailbox domains such as gmail.com or outlook.com.
Verify a domain
POST /domains/{id}/verify looks up all four records now and returns the updated domain. Each record's status is verified, not_found (missing or wrong) or temporary_failure (DNS didn't answer, so try again). The domain becomes verified once all four records pass. New DNS records can take a few minutes to an hour to show up.
If you remove or change a record after the domain is verified, the domain becomes failed and sending from it stops until you fix the record and verify again.
List, get and delete
| Request | What it does |
|---|---|
GET /domains |
Lists your domains: {"object": "list", "data": [ … ]}. |
GET /domains/{id} |
Returns one domain, with the result of the last check. |
PATCH /domains/{id} |
Changes the domain's settings: open_tracking and click_tracking. See Open and click tracking. |
DELETE /domains/{id} |
Removes the domain. You can no longer send from it. |
Open and click tracking
Tracking is off by default. You switch it on for each sending domain:
curl -X PATCH https://api.ownmailserver.com/domains/0b8e4f7a-3c2d-4e1f-8a9b-6c5d4e3f2a1b \
-H "Authorization: Bearer $OMS_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "open_tracking": true, "click_tracking": true }'
From then on, in the HTML of every email you send from that domain through the API:
- Click tracking changes each
httpandhttpslink to go throughclick.ownmailserver.com/c/…, which records the click and redirects to your URL. We leavemailto:andtel:links,#anchorsand anything that looks like an unsubscribe or opt-out link as they are. - Open tracking adds a 1×1 transparent image at the end of the email. When it loads, we record an open.
Each open or click becomes an opened or clicked event on the email, and is sent to your email.opened and email.clicked webhooks. A click's event includes the URL. Open and click rates also appear in the Claude connector's statistics.
Some things to know before you rely on the numbers:
- Opens are approximate. Apple Mail loads images for privacy, even if nobody reads the email. Some security scanners also load images and follow links before a person sees the message.
- Plain-text emails and parts can't be tracked. Neither can email you send through SMTP.
- If an email has several recipients, we can't tell which one opened or clicked. The event's
recipientis thennull. Send one email per recipient if you need to know. - Tracked links show
click.ownmailserver.com, not your domain, when someone hovers over them. That host only serves tracking links, so the API's reputation and yours stay separate. If showing our domain matters for your brand, leave click tracking off.
Suppressions
Your suppression list holds the addresses we won't send to. We add addresses to it automatically, and you can add or remove them yourself.
| Reason | Added by | Blocks |
|---|---|---|
hard_bounce |
Us, when the address doesn't exist | All mail |
complaint |
Us, when the recipient reports your mail as spam | All mail |
list_unsubscribe |
Us, when the recipient clicks a one-click unsubscribe link | Marketing mail only |
unsubscribe |
You, through the API | All mail |
manual |
You, through the API or the dashboard | All mail |
List suppressions
GET /suppressions returns the newest first. Optional query parameters: email, reason, and limit (default 100, maximum 1000).
{
"object": "list",
"data": [
{ "email": "old@example.com", "reason": "hard_bounce", "message_id": "5f0c1a52-…",
"detail": "550 5.1.1 The email account that you tried to reach does not exist", "created_at": "2026-10-27T09:15:44.000Z" }
]
}
Add a suppression
POST /suppressions with {"email": "jane@example.com", "reason": "unsubscribe"} returns 201. reason is either unsubscribe or manual, and defaults to manual. If the address is already suppressed, it keeps its existing reason.
Remove a suppression
DELETE /suppressions/{email} removes the address so you can send to it again. If the address was suppressed for a spam complaint, the response includes a warning. Only email that person again if they asked you to, because sending to people who complained damages your reputation quickly.
Webhooks
Webhooks send events to your server as they happen, so you don't have to poll.
Create a webhook
POST /webhooks:
{
"endpoint": "https://acme.com/hooks/email",
"events": ["email.delivered", "email.bounced", "email.complained"]
}
The endpoint must be a public https:// URL. The response (201) includes a signing_secret that starts with whsec_. We show the secret only in this response, so store it right away. GET /webhooks lists your endpoints and DELETE /webhooks/{id} removes one.
Event types
| Event | When |
|---|---|
email.sent |
Our mail server accepted the email for delivery. |
email.delivered |
The recipient's mail server accepted it. |
email.delivery_delayed |
The recipient's server asked us to try again later. We keep retrying. |
email.bounced |
It was refused for good. bounce.type is hard (no such address), soft (for example, a full mailbox) or block (refused by policy or reputation). |
email.complained |
The recipient reported it as spam. |
email.unsubscribed |
The recipient used the one-click unsubscribe link. |
email.opened |
The tracking image loaded. Only sent if open tracking is on. |
email.clicked |
A tracked link was clicked. click.url is the link. Only sent if click tracking is on. |
email.failed |
We couldn't send it at all. |
These names match Resend's where Resend has the same event.
Payload
{
"type": "email.bounced",
"created_at": "2026-10-27T14:05:31.204Z",
"data": {
"email_id": "5f0c1a52-8f3e-4c1b-9a0e-2a8f1d6b7c34",
"from": "Acme <hello@acme.com>",
"to": ["old@example.com"],
"subject": "Your receipt",
"tags": [{ "name": "category", "value": "receipt" }],
"recipient": "old@example.com",
"bounce": {
"type": "hard",
"status": "5.1.1",
"diagnostic": "550 5.1.1 The email account that you tried to reach does not exist",
"source": "smtp"
}
}
}
The rest of data depends on the event type. email.delivered and email.delivery_delayed include the receiving server's response. email.complained includes a complaint object, email.unsubscribed includes an unsubscribe object, and email.failed includes an error.
Verify signatures
Webhooks are signed using the Standard Webhooks scheme, the same one Resend uses through Svix. Each request carries three headers: webhook-id, webhook-timestamp and webhook-signature. The signature is v1, followed by a base64 HMAC-SHA256 of {webhook-id}.{webhook-timestamp}.{raw body}. The HMAC key is the base64-decoded part of your secret after whsec_.
You can use an existing Standard Webhooks library, or check the signature yourself:
import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody must be the exact bytes we sent, before any JSON parsing.
function verify(rawBody, headers, secret) {
const id = headers["webhook-id"];
const ts = headers["webhook-timestamp"];
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; // reject replays older than 5 minutes
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = "v1," + createHmac("sha256", key).update(`${id}.${ts}.${rawBody}`).digest("base64");
return headers["webhook-signature"].split(" ").some((sig) =>
sig.length === expected.length && timingSafeEqual(Buffer.from(sig), Buffer.from(expected)));
}
Retries
Reply with any 2xx status within 10 seconds. We don't follow redirects. If a delivery fails, we try again after 30 seconds, 2 minutes, 10 minutes, 1 hour, 6 hours and 24 hours, which makes 7 attempts in total. Every attempt has the same webhook-id, so you can use it to skip events you've already handled.
Errors
Errors come back as JSON in the same format Resend uses:
{ "statusCode": 403, "name": "domain_not_verified", "message": "The domain acme.com is not verified yet" }
Check name in your code. message is written for people and may change.
| Status | name |
What to do |
|---|---|---|
| 401 | missing_api_key |
Send a valid key in the Authorization header. |
| 403 | account_pending |
Wait for approval. We'll email you when your account is approved. |
| 403 | account_paused, account_suspended |
Sending is stopped. Check your email or contact us. |
| 403 | domain_not_found |
Add the from domain to your account first. |
| 403 | domain_not_verified |
Publish the DNS records, then verify the domain. |
| 404 | not_found |
The email, domain, webhook or suppression doesn't exist on this account. |
| 409 | domain_exists |
The domain is already on your account. |
| 409 | domain_claimed |
Another account has already verified this domain. Contact us if it's yours. |
| 409 | invalid_idempotent_request |
This Idempotency-Key was already used with a different request. Use a new key. |
| 409 | concurrent_idempotent_requests |
A request with this key is still running. Wait a moment and retry. |
| 409 | not_scheduled |
Only scheduled emails can be rescheduled or canceled. |
| 413 | payload_too_large |
The email is larger than 10 MB. |
| 422 | validation_error |
The request is malformed. message says which field is wrong. |
| 429 | rate_limit_exceeded |
Slow down. Wait for the number of seconds in the retry-after header. |
| 429 | daily_quota_exceeded |
You've reached the warm-up limit for the last 24 hours. Try later. |
| 500 | internal_server_error |
A problem on our side. It's safe to retry. |
Every authenticated response includes ratelimit-limit and ratelimit-remaining headers.
SMTP
If your app can only send through SMTP, use these settings. It also works for software you can't change, such as WordPress plugins and other off-the-shelf apps:
| Setting | Value |
|---|---|
| Host | smtp.ownmailserver.com |
| Port | 587, with STARTTLS (required before login) |
| Username | ownmailserver (we accept any value) |
| Password | An API key |
Email sent through SMTP goes through the same checks as the API: verified from domain, suppression list, warm-up cap, 50 recipients and 10 MB. We send your message as you built it, attachments included, and add the DKIM signature. Send the X-OMS-Unsubscribe header to control unsubscribe links, as described above. After 5 failed logins from one IP address, logins from that address are blocked for 15 minutes.
When we refuse a message, the SMTP reply code tells your app whether to retry:
| Code | Meaning |
|---|---|
450 / 452 |
Temporary: too many messages per second, the warm-up cap is reached, or more than 50 recipients. Your app should retry. |
535 |
Login failed. Use an API key as the password. |
550 |
The from domain isn't verified, or the account is pending or paused. |
552 |
The message is larger than 10 MB. |
Moving from Resend
Our API follows Resend's request shapes, response shapes, error format, webhook event names and webhook signatures. We test Resend's own SDKs against it: resend for Node.js (version 6) and resend for Python (version 2). Sending, attachments, idempotency keys, scheduling, batches, listing emails, domains and webhooks all work. To switch, keep your code and change two settings:
import { Resend } from "resend";
const resend = new Resend(process.env.OMS_API_KEY, { baseUrl: "https://api.ownmailserver.com" });
// Or leave the code alone and set RESEND_BASE_URL=https://api.ownmailserver.com
import os, resend
resend.api_key = os.environ["OMS_API_KEY"]
resend.api_url = "https://api.ownmailserver.com" # or set RESEND_API_URL
Where we differ from Resend:
- Attachments can't come from a URL. Send the file itself (
content), notpath. scheduled_attakes ISO 8601 with a time zone. Plain-English times such as "in 1 hour" aren't accepted.- Batches are always all or nothing. Resend's "permissive" batch mode isn't supported.
- We only cover sending. Resend's contacts, audiences, broadcasts, templates and inbound email aren't part of our API.
Our Resend migration guide covers the rest: DNS, IP warm-up, and how to run both providers side by side while you switch.
Claude connector
Every account also has a read-only MCP server at https://api.ownmailserver.com/mcp. With it, Claude can answer questions about your delivery, bounces, complaints and domain health. It signs in with OAuth instead of an API key. See the Claude connector page.