· Iqbal Abdullah Iqbal Abdullah

Send Custom Domain Email from Python with KaiMail's HTTP API

ending Custom Domain Email from a serverless environment or restricted VPC can be challenging when SMTP is blocked. This Python guide demonstrates how to use the KaiMail HTTP API to send reliable messages, manage attachments, and monitor delivery.


Developer writing code in an IDE

When we launched the KaiMail HTTP Sending API back in July, I thought the story was straightforward. POST some JSON, get a tracking ID, move on.

Then I started using it myself. And I kept running into the same friction points: a 401 that turned out to be the wrong password, a 403 because I forgot to register the mailbox, a quota error that I only caught in production. Each time I thought: "there should be a guide that walks through the whole thing, including the ways it breaks."

This is that guide.

Why Use the HTTP API Instead of SMTP

SMTP on port 587 is the standard. It works everywhere that allows it. But "everywhere" is doing a lot of work there.

AWS Lambda in a restrictive VPC, Netlify Functions, some DigitalOcean droplets, and a surprising number of managed hosts simply block outbound SMTP. Even when they do not block it, SMTP handshakes can time out in serverless environments where you are billed by the millisecond.

The HTTP API is an alternative front door to the same pipeline. Your message still gets DKIM-signed, still goes through the same validation, still lands in the same delivery logs. The only difference is how you talk to us: JSON over HTTPS instead of MIME over STARTTLS.

If your code can POST to a webhook, it can send email.

What You Need

Before you write any code, make sure you have:

  1. A paid KaiMail plan with SMTP sending enabled.
  2. A custom domain registered on KaiMail, with DKIM and SPF records published.
  3. A mailbox registered on that domain — the full address you plan to send from.
  4. Your SMTP password from the KaiMail dashboard. This is not your login password. It is a separate credential shown on your profile page.

Without step 3, every send will fail with mailbox_not_registered. I know because I spent twenty minutes debugging this myself.

The Simplest Possible Send

The API lives at https://kaimail.net/api/v1. It uses HTTP Basic Auth with your KaiMail account email as the username and your SMTP password as the password.

Here is the minimal working example:

import os
import requests

API_BASE = "https://kaimail.net/api/v1"
AUTH = (os.environ["KAIMAIL_USER"], os.environ["KAIMAIL_PASS"])

resp = requests.post(
    f"{API_BASE}/email/send",
    auth=AUTH,
    json={
        "from": "Support <[email protected]>",
        "to": ["[email protected]"],
        "subject": "Welcome aboard",
        "text": "Hello — thanks for signing up.",
    },
    timeout=30,
)

print(resp.status_code)
print(resp.json())

Save it, set KAIMAIL_USER and KAIMAIL_PASS in your environment, and run it. If everything is configured, you get 202 Accepted:

{
  "request_id": "0f6d2c9be5a34f7f9f0c1d2e3a4b5c6d",
  "data": {
    "tracking_id": "k2vq81xw3n7p5d09smc4",
    "status": "queued",
    "recipients": ["[email protected]"]
  }
}

202 means the message passed validation and was queued. It does not mean it was delivered. For that, you need to poll the status endpoint.

Sending HTML Email

The text field is plain text. If you want HTML, add an html field. If you provide both, the server builds a multipart/alternative message automatically.

import os
import requests

API_BASE = "https://kaimail.net/api/v1"
AUTH = (os.environ["KAIMAIL_USER"], os.environ["KAIMAIL_PASS"])

resp = requests.post(
    f"{API_BASE}/email/send",
    auth=AUTH,
    json={
        "from": "Support <[email protected]>",
        "to": ["[email protected]"],
        "subject": "Welcome aboard",
        "text": "Hello — thanks for signing up.",
        "html": "<p>Hello — thanks for <strong>signing up</strong>.</p>",
    },
    timeout=30,
)

print(resp.status_code)
print(resp.json())

Sending to Multiple Recipients

The to field accepts either a single string or an array of strings. Maximum 100 recipients per message.

import os
import requests

API_BASE = "https://kaimail.net/api/v1"
AUTH = (os.environ["KAIMAIL_USER"], os.environ["KAIMAIL_PASS"])

resp = requests.post(
    f"{API_BASE}/email/send",
    auth=AUTH,
    json={
        "from": "Support <[email protected]>",
        "to": ["[email protected]", "[email protected]", "[email protected]"],
        "subject": "Welcome aboard",
        "text": "Hello — thanks for signing up.",
    },
    timeout=30,
)

print(resp.status_code)
print(resp.json())

Important: all recipients appear in the message's To: header. If you need Bcc semantics — where a recipient receives the message without appearing in the headers — you need the send-raw endpoint instead.

Sending Attachments with send-raw

For attachments, custom headers, or full control over the MIME structure, use POST /api/v1/email/send-raw. You supply a complete RFC 822 message, base64-encoded, plus the envelope recipients.

Put invoice.pdf in the same directory as the script, then run:

import os
import base64
import requests
from email.message import EmailMessage

API_BASE = "https://kaimail.net/api/v1"
AUTH = (os.environ["KAIMAIL_USER"], os.environ["KAIMAIL_PASS"])

msg = EmailMessage()
msg["From"] = "Billing <[email protected]>"
msg["To"] = "[email protected]"
msg["Subject"] = "Your invoice"
msg.set_content("Invoice attached.")

with open("invoice.pdf", "rb") as f:
    msg.add_attachment(
        f.read(),
        maintype="application",
        subtype="pdf",
        filename="invoice.pdf",
    )

resp = requests.post(
    f"{API_BASE}/email/send-raw",
    auth=AUTH,
    json={
        "raw_message": base64.b64encode(msg.as_bytes()).decode("ascii"),
        "to": ["[email protected]"],
        "envelope_from": "[email protected]",
    },
    timeout=30,
)

print(resp.status_code)
print(resp.json())

The to field here is the SMTP envelope — the actual delivery addresses. The To: header inside the message is for display only. This separation is how Bcc works: include the address in the envelope to array but omit it from the message headers.

Checking the Status of a Send

After you get a tracking_id, poll GET /api/v1/email/<tracking_id> to see what happened. Paste the id from the send response:

import os
import requests

API_BASE = "https://kaimail.net/api/v1"
AUTH = (os.environ["KAIMAIL_USER"], os.environ["KAIMAIL_PASS"])
tracking_id = "k2vq81xw3n7p5d09smc4"

status_resp = requests.get(
    f"{API_BASE}/email/{tracking_id}",
    auth=AUTH,
    timeout=30,
)

print(status_resp.status_code)
print(status_resp.json())

A successful send looks like this:

{
  "request_id": "6b1a2c3d4e5f60718293a4b5c6d7e8f9",
  "data": {
    "tracking_id": "k2vq81xw3n7p5d09smc4",
    "status": "sent",
    "envelope_from": "[email protected]",
    "recipients": ["[email protected]"],
    "subject": "Welcome aboard",
    "created_time": "2026-07-20T04:12:33Z",
    "processed_time": "2026-07-20T04:12:41Z",
    "error": null
  }
}

The status lifecycle is simple: queuedsendingsent. Or queuedsendingfailed if the recipient server rejects it after three retries.

Note that sent means "accepted for delivery by KaiMail's mail server." It does not mean the recipient's inbox received it. For bounces and per-recipient results, check the Logs dashboard.

Checking Your Quota

Before you send a batch, check how much quota you have left:

import os
import requests

API_BASE = "https://kaimail.net/api/v1"
AUTH = (os.environ["KAIMAIL_USER"], os.environ["KAIMAIL_PASS"])

usage_resp = requests.get(
    f"{API_BASE}/usage",
    auth=AUTH,
    timeout=30,
)

print(usage_resp.status_code)
print(usage_resp.json())

Response:

{
  "request_id": "9c8b7a6d5e4f30211203f4e5d6c7b8a9",
  "data": {
    "smtp_sending": true,
    "sent_this_period": 42,
    "quota": 1000,
    "remaining": 958,
    "period_days": 30
  }
}

Quota is counted per recipient. One message to ten people consumes ten units. Messages already queued but not yet sent also count, so a burst of queued sends cannot overshoot your limit.

Error Handling: The Errors You Will Actually See

The API returns machine-readable error codes. Here are the ones that come up in practice.

invalid_credentials (401)

You used the wrong password. Use your SMTP password, not your dashboard login password. These are different secrets on purpose, so you can rotate API credentials without locking yourself out of the web interface.

sending_not_allowed (403)

Your plan does not include outbound sending. Upgrade to Plus, Pro, or Business.

domain_not_owned (403)

The sender's domain is not registered on your KaiMail account. You cannot send from addresses you do not own. Add the domain in the dashboard first.

mailbox_not_registered (403)

The domain is yours, but the specific sender address is not a registered mailbox. This got me. I added yourdomain.com and tried to send from [email protected], but I had not actually created the hello mailbox. Create the mailbox in the dashboard, then try again.

quota_exceeded (429)

You hit your monthly limit. Check /usage and back off. If you are close to the limit, query usage before sending large batches.

invalid_request (400)

A field is missing or malformed. Common causes: missing from, missing to, no text or html provided, malformed email address, or more than 100 recipients. The error message tells you which field failed.

invalid_message (400)

Only for send-raw. Your raw_message is not valid base64, or it decodes to an empty message. Make sure you are using standard base64 with padding.

message_too_large (413)

The decoded message exceeds your plan's size limit. Base64 inflates the payload by about 33%, so the limit applies to the decoded bytes, not the encoded string.

A Reusable Python Client

Here is a small module you can drop into a project. It wraps the API, handles errors, and polls status:

import os
import time
from typing import Literal

import requests

class KaiMailClient:
    def __init__(self, username: str, password: str, base_url: str = "https://kaimail.net/api/v1"):
        self.base_url = base_url.rstrip("/")
        self.auth = (username, password)

    def _post(self, path: str, payload: dict) -> dict:
        resp = requests.post(
            f"{self.base_url}{path}",
            auth=self.auth,
            json=payload,
            timeout=30,
        )
        data = resp.json()
        if resp.status_code != 202:
            raise KaiMailError(data["error"]["code"], data["error"]["message"], data["request_id"])
        return data

    def _get(self, path: str) -> dict:
        resp = requests.get(f"{self.base_url}{path}", auth=self.auth, timeout=30)
        resp.raise_for_status()
        return resp.json()

    def send(
        self,
        from_addr: str,
        to_addrs: list[str],
        subject: str,
        text: str | None = None,
        html: str | None = None,
    ) -> str:
        if not text and not html:
            raise ValueError("Provide at least one of text or html")
        payload = {
            "from": from_addr,
            "to": to_addrs,
            "subject": subject,
        }
        if text:
            payload["text"] = text
        if html:
            payload["html"] = html
        data = self._post("/email/send", payload)
        return data["data"]["tracking_id"]

    def send_raw(self, raw_message_b64: str, to_addrs: list[str], envelope_from: str | None = None) -> str:
        payload = {"raw_message": raw_message_b64, "to": to_addrs}
        if envelope_from:
            payload["envelope_from"] = envelope_from
        data = self._post("/email/send-raw", payload)
        return data["data"]["tracking_id"]

    def get_status(self, tracking_id: str) -> dict:
        return self._get(f"/email/{tracking_id}")

    def wait_for_status(
        self,
        tracking_id: str,
        desired: Literal["sent", "failed"] = "sent",
        max_attempts: int = 10,
        delay: float = 2.0,
    ) -> dict:
        for _ in range(max_attempts):
            data = self.get_status(tracking_id)
            status = data["data"]["status"]
            if status in (desired, "failed"):
                return data
            time.sleep(delay)
        raise TimeoutError(f"Status did not reach {desired} after {max_attempts} attempts")

    def usage(self) -> dict:
        return self._get("/usage")


class KaiMailError(Exception):
    def __init__(self, code: str, message: str, request_id: str):
        self.code = code
        self.message = message
        self.request_id = request_id
        super().__init__(f"[{code}] {message} (request_id: {request_id})")


# Example usage
if __name__ == "__main__":
    client = KaiMailClient(
        os.environ["KAIMAIL_USER"],
        os.environ["KAIMAIL_PASS"],
    )

    tracking_id = client.send(
        from_addr="Support <[email protected]>",
        to_addrs=["[email protected]"],
        subject="Welcome aboard",
        text="Hello — thanks for signing up.",
    )
    print(f"Queued: {tracking_id}")

    result = client.wait_for_status(tracking_id)
    print(f"Final status: {result['data']['status']}")

Integration Checklist

Before you ship code that calls this API:

  1. Domain registered on KaiMail. DKIM record published.
  2. Mailbox created for the sender address you plan to use.
  3. SMTP password stored securely — environment variable, secret manager, not in source code.
  4. Error handling switches on error.code, not the message text.
  5. Quota checked before large batches.
  6. tracking_id stored somewhere you can query later for debugging.
  7. Status polling has a timeout and does not hang forever.
  8. For send-raw, verify base64 encoding includes padding and the decoded size fits your plan limit.

When to Use SMTP Instead

The HTTP API is not a replacement for SMTP. It is an alternative for when SMTP is inconvenient or impossible.

Use SMTP when: you are sending from a desktop email client, a mailing list manager, or any application that already speaks SMTP natively.

Use HTTP when: your platform blocks port 587, you are in a serverless environment, JSON is easier than MIME generation, or you want programmatic access to send status and quota.

What Is Next

The HTTP API is stable and we are using it in production ourselves. If you hit something unexpected, every response includes a request_id. Include that when you contact us and we can trace exactly what happened.

Give it a try. Start with a single POST, wrap it in a small client, and build from there.

Reference links: