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_urlthat is nothttpsis rejected at construction, unless the host islocalhost,127.0.0.1or::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 —
Authorizationand all — at whatever hostLocationnames. A 3xx surfaces asServerError("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 shownmail_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_typeorcontent_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:htmlandtextas 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_…ornc_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 todelivered@,bounced@orcomplained@test.mail.naijacloud.devto get that outcome. A message sent this way comes back fromgetwithsandboxset 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)
| File | Size | Uploaded | |
|---|---|---|---|
| naijacloud_email-0.3.1.tar.gz | 57.7 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| 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 logRelease 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