Skip to main content

Naijamail — Python SDK

pypi python dependencies license

Quickstart · The API · Options · Errors · Retries · Webhooks · Security

Naijamail for Python

The official Python SDK for Naijamail, Naija Cloud's transactional email API.

Zero runtime dependencies. Python 3.9 and newer.

pip install naijacloud-email

Quickstart

from nc_email import Naijamail

nm = Naijamail()  # reads NAIJAMAIL_API_KEY

sent = nm.emails.send(
    from_="Acme <hello@acme.com>",
    to="customer@example.com",
    subject="Your receipt",
    html="<p>Thanks for your order.</p>",
)
print(sent.id, sent.status)          # 5b1e… queued

email = nm.emails.get(sent.id)
print(email.status, email.delivered_at)

from_ carries a trailing underscore because from is a reserved word. If you are porting from another SDK you can pass the whole thing as a dict instead, and spell it from:

nm.emails.send({
    "from": "Acme <hello@acme.com>",
    "to": ["a@example.com", "b@example.com"],
    "reply_to": "support@acme.com",
    "subject": "Invoice #1024",
    "html": "<p>Attached.</p>",
    "attachments": [
        {"filename": "invoice-1024.pdf", "content": pdf_bytes, "content_type": "application/pdf"},
    ],
    "tags": {"campaign": "invoices"},
})

The API

This release wraps two endpoints, send and retrieve. The API also has batch send, a message list, limits, domains and suppressions (API docs); they are not wrapped yet.

nm.emails.send(...) POST /v1/emails — returns SendEmailResponse(id, status, rejected, sandbox)
nm.emails.get(id) GET /v1/emails/{id} — returns Email

Call the other routes over plain HTTPS with the same key until they are wrapped.

Sending

Parameter Type Required Notes
from_ (or from) str yes "Name <a@b.com>" or "a@b.com". The domain must be verified for your team.
to str | list[str] yes At least one address.
cc, bcc, reply_to str | list[str] no replyTo is accepted as well; the SDK always sends reply_to.
subject str yes The API refuses a missing or blank subject with a 400 subject is required.
html, text str one of them At least one is required (400 provide html, text, or both); send both for the best inbox placement.
headers dict[str, str] no At most 25. From, To, Cc, Bcc, Subject, DKIM-Signature and Received are refused, case-insensitively and with surrounding whitespace ignored (" From" is refused too).
attachments list[dict] no {filename, content, content_type?, content_id?}
tags dict[str, str] no At most 10; keys up to 64 chars, values up to 256.
idempotency_key str no One is generated for you if you leave it out or pass "". At most 255 bytes of UTF-8. Sent as the Idempotency-Key header only.

SendEmailResponse.rejected is always a list, empty when nobody was rejected — the API omits the field entirely in that case, and you should not have to branch on absence. A non-empty rejected is not an error: those recipients are on the suppression list and the rest of the message still went out.

Email.from_ carries the same trailing underscore, for the same reason.

Email.created_at and Email.delivered_at are the ISO 8601 strings the API sends ("2026-08-29T10:00:00.000Z"), not datetime objects — parse them with datetime.fromisoformat(value.replace("Z", "+00:00")) if you need one. The other SDKs surface their language's own date type; that difference is deliberate.

Statuses arrive as plain lowercase strings: queued, sent, delivered, bounced, deferred, complained, rejected, failed. Compare them against MessageStatus.DELIVERED and friends. They are constants rather than an enum on purpose — a strict enum would raise the day the platform adds a status, breaking every SDK older than that day. Use MessageStatus.is_known(status) if you need to notice a new one.

Attachments

Pass raw bytes. The SDK base64-encodes them:

with open("invoice.pdf", "rb") as handle:
    content = handle.read()

nm.emails.send(
    from_="Acme <hello@acme.com>",
    to="customer@example.com",
    subject="Invoice",
    text="Attached.",
    attachments=[{"filename": "invoice.pdf", "content": content}],
)

bytes, bytearray and memoryview are raw content. A str is accepted only as already-encoded base64, and is validated strictly rather than silently mangled. Empty content is refused (the API refuses it too). A file path is never accepted: an SDK that opens arbitrary paths on your behalf becomes a local-file-read primitive the moment one of those paths comes from an HTTP request. Read the file yourself.

Options

nm = Naijamail(
    api_key=None,             # else NAIJAMAIL_API_KEY
    base_url=None,            # else NAIJAMAIL_BASE_URL, else https://api.naijacloud.com
    timeout=30.0,             # seconds, a deadline for each whole attempt; must be > 0
    max_retries=2,            # 3 attempts in total; 0 to 10
    user_agent_suffix=None,   # appended to nc-email-python/<version> (python/<version>)
)

A blank NAIJAMAIL_BASE_URL counts as unset. A base_url carrying a query string or a fragment is refused rather than silently stripped. timeout bounds connecting, sending and reading the whole response together — a server that trickles bytes cannot keep an attempt alive past it — and each retry gets a fresh deadline.

A client owns its key, its base URL and its HTTP opener. Nothing is held at module level, so two clients with two keys in one process cannot interfere.

Errors

Every failure is a subclass of NaijamailError, carrying message, status_code, error (the server's short label), request_id (from x-request-id), raw_body (the response text exactly as received, "" for a local error) and parsed_body (the decoded JSON, or None if it was not JSON). body is kept for compatibility: the parsed JSON when there was some, otherwise the text.

HTTP Exception Retried
400 ValidationError (NotFoundError when the message is message not found) no
401 AuthenticationError no
403 NaijamailPermissionError no
404 NotFoundError no
408 NaijamailTimeoutError yes
409 ConflictError no
413, 422 ValidationError no
any other 4xx (405, 415, 451…) ValidationError no
429 RateLimitError (.retry_after in seconds, at most 60) yes
5xx ServerError yes
socket / DNS / TLS NaijamailConnectionError yes
client-side deadline NaijamailTimeoutError yes
2xx that is not JSON, or a send response with no id ServerError ("malformed response") no
caught before sending ValidationError (status_code == 0) no
from nc_email import Naijamail, NaijamailError, RateLimitError

try:
    nm.emails.send(from_="hello@acme.com", to="customer@example.com", subject="Hi")
except RateLimitError as exc:
    print("slow down for", exc.retry_after, "seconds")
except NaijamailError as exc:
    print(exc.status_code, exc.message, exc.request_id)

ConnectionError, TimeoutError and PermissionError are Python builtins, so the canonical class names are prefixed — NaijamailConnectionError, NaijamailTimeoutError, NaijamailPermissionError. The short spellings exist as aliases (nc_email.TimeoutError) but are kept out of __all__, so a from nc_email import * cannot quietly replace a builtin in your module and change the meaning of an unrelated except.

Retrieving an id that does not exist raises NotFoundError even though the API answers 400 for it. That is a known server quirk, mapped here so your except reads the way you expect.

Retries

Three attempts by default, 30 seconds each. Retried on 429, 408, any 5xx, and connection or timeout failures. Never on any other 4xx — a 403 for an unverified domain will not succeed on the second try.

Backoff is exponential with full jitter (random(0, min(8s, 0.5s * 2^attempt))). A Retry-After header overrides it on any retried response — a 503 as well as a 429 — in either its integer-seconds or its HTTP-date spelling, clamped to 60 seconds. It is exposed as .retry_after on the error.

Retrying a POST is only safe because of idempotency. If you do not supply an idempotency_key, the SDK generates one UUIDv4 per send() call and sends it as Idempotency-Key on every attempt of that call. Without that, a lost response followed by a retry would send your customer two copies of the same receipt. A key you supply yourself is used as given and never regenerated.

Security

The rules the SDK enforces, and why:

  • HTTPS only. A base_url that is not https is rejected at construction, unless the host is localhost, 127.0.0.1 or ::1. A plaintext base URL would put a live sending credential on the wire in clear.
  • Redirects are refused, never followed. urllib's default handler replays the original request — Authorization and all — at whatever host Location names. A 3xx surfaces as ServerError("unexpected redirect").
  • TLS verification is explicit. The SDK builds its own ssl.create_default_context() with certificate and hostname checks forced on, so its behaviour does not depend on a process-wide default someone loosened.
  • The key never leaves except in the auth header. repr(), str() and the User-Agent all show nmail_live_***; the key is not an attribute of the client object; the client refuses to pickle.
  • Header-injection defence. CR, LF or NUL in from, any address, subject, a custom header name or value, an attachment filename, content_type or content_id, or the idempotency key is rejected before any network call.
  • Client-side limits, mirroring the server: 50 recipients across to+cc+bcc, 10 MiB of message, 25 headers, 10 tags. The size is measured the way the server measures it: html and text as UTF-8 plus the decoded attachment bytes — so a 9 MiB attachment goes through even though it is ~12 MiB once base64-encoded.
  • Key shape is checked at construction (nmail_live_…, nmail_test_… or nc_live_…, surrounding whitespace trimmed), so a bad key fails at deploy rather than as a 401 during a customer's checkout.

A test key (nmail_test_…) is sandboxed: sends are recorded and answered with a real id, never delivered, so staging and CI can exercise the whole flow without mailing anyone. SendEmailResponse.sandbox and Email.sandbox are true for those messages.

Which key

Two kinds work, and the SDK cannot tell them apart once it has one:

  • nc_live_… — a workspace API key from Settings → API keys, ticked for the Email send scope. Most teams already have one: it is the same credential CI deploys with. Add Platform API as well if the key also needs to manage sending domains or suppressions.
  • nmail_live_… / nmail_test_… — a Naijamail-only key from Email. The test variant is sandboxed: the API accepts the send, returns a real id and a final status, and never hands the message to a mail server. Use one in staging and CI. Send from any domain you have added, or from …@test.mail.naijacloud.dev; send to delivered@, bounced@ or complained@test.mail.naijacloud.dev to get that outcome. A message sent this way comes back from get with sandbox set to true. There is no test variant of a workspace key.

An nc_pat_… platform token is not accepted: those predate the Email send scope and the API refuses them on the mail routes, so the SDK refuses them at construction rather than a request later, with a message saying it is a personal access token and which keys to use instead.

Report a vulnerability to security@naijacloud.com. See SECURITY.md.

Webhooks

Live. Naija Cloud delivers these events to endpoints you register, signed exactly as below. Two details this verifier already handles: the timestamp is taken per delivery attempt, so a retry never arrives outside the tolerance window; and during a secret rotation the header carries two v1= values for 24 hours, which is why any match is accepted.

from nc_email import Webhooks, WebhookVerificationError

@app.post("/webhooks/naijamail")
def handle():
    try:
        event = Webhooks.verify(
            request.get_data(),                    # the raw body bytes, not a parsed dict
            request.headers.get("NC-Signature"),
            os.environ["NAIJAMAIL_WEBHOOK_SECRET"],
        )
    except WebhookVerificationError:
        return "", 400
    return "", 204

The header is NC-Signature: t=<unix seconds>,v1=<hex sha256 hmac>, over "<t>.<raw body>". Several v1= values may be present during a secret rotation, and any one matching is enough. Timestamps outside 300 seconds are rejected, which is what stops a captured request being replayed later; tolerance=0 means strict (this second only), not "the default". t must be plain ASCII digits (at most 12), the hex signature is compared case-insensitively, and the payload must be a JSON object. The comparison is constant-time, and a failure never tells you the expected signature.

Pass the raw bytes. A parsed object re-serialised differs from what was signed by key order and spacing, so it would fail verification at random.

Contributing

See CONTRIBUTING.md. The test suite needs no network and no third-party packages:

python -m unittest discover -s tests -v

License

MIT. Copyright (c) 2026 Naija Cloud.

Metadata

Release files for naijacloud-email 0.3.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for naijacloud-email 0.3.1
File Size Uploaded
naijacloud_email-0.3.1.tar.gz 57.7 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for naijacloud-email 0.3.1
File Interpreter ABI Platform
naijacloud_email-0.3.1-py3-none-any.whl Python 3 none any Details

Total release size: 95.4 kB

Release files / naijacloud_email-0.3.1.tar.gz

Download URL naijacloud_email-0.3.1.tar.gz
Size 57.7 kB
Tags Source
SHA-256 checksum
How to use checksums
1409b6e85a424d0a7de909f828c1b1522ecb59497003b4abcc420ac65725d010
BLAKE2b-256 checksum
How to use checksums
833a045792e3a3bfd1271de01bb6f15260cae6652a019c9724cdf8605e49bfb8
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 15, 2026.

Transparency log

Release files / naijacloud_email-0.3.1-py3-none-any.whl

Download URL naijacloud_email-0.3.1-py3-none-any.whl
Size 37.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
522512fec3dd8c49be8a5fbbd8477c2d87ee298ecb1495ebcc82ffd35f3ae7c6
BLAKE2b-256 checksum
How to use checksums
7c524b1e60feb53b265a45767225290f65e4c2aec43dc4b84c49bd9a272dbcf1
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 15, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.3.1 This release

2 release files

0.3.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page