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

  1. Sign up at app.ownmailserver.com. We review every account, usually within one business day.
  2. Add your sending domain and publish the four DNS records the dashboard shows you. The API can do this too, see Domains.
  3. Create an API key under API keys & SMTP. The key is shown only once, so copy it straight away.
  4. 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 http and https link to go through click.ownmailserver.com/c/…, which records the click and redirects to your URL. We leave mailto: and tel: links, #anchors and 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 recipient is then null. 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), not path.
  • scheduled_at takes 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.