API
/
Sending email
Sending email
Transactional email over plain REST at `https://api.naijacloud.com/v1`. An API key with the Email send scope is all it takes, and there are SDKs for Node, Python, Go, Ruby and PHP that wrap the same endpoints.
7 min read
Before you can send
Two things, in order:
- Verify a sending domain. You can only send from a domain the workspace has proved it owns. Add it under Email → Domains, publish the DNS records it shows you, and verify. We generate your DKIM keypair and sign messages ourselves, so those records are yours and ours — they do not change if we change mail providers behind the scenes.
- Create a key under Settings → API keys with the Email send scope.
Two credential shapes work here. An nc_live_ workspace key with Email send is
the one to use — it is the same credential your CI already holds. An
nmail_live_ key from the Email screen also works and is Naijamail-only. An
nc_pat_ platform token does not: it predates the scope and is refused.
Send
POST /v1/emails
curl https://api.naijacloud.com/v1/emails \
-H "Authorization: Bearer $NC_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-4417-receipt" \
-d '{
"from": "Acme <[email protected]>",
"to": ["[email protected]"],
"subject": "Your receipt",
"html": "<p>Thanks for your order.</p>",
"text": "Thanks for your order.",
"tags": { "kind": "receipt" }
}'| Field | Type | Required | Notes |
|---|---|---|---|
from | string | yes | "Name <[email protected]>" or "[email protected]". The domain must be verified |
to | string or string[] | yes | At least one address |
cc, bcc | string or string[] | no | |
reply_to | string or string[] | no | replyTo is accepted too |
subject | string | no | Defaults to empty |
html, text | string | no | Send both where you can |
headers | map | no | Up to 25. From, To, Cc, Bcc, Subject, DKIM-Signature and Received are refused |
attachments | array | no | { filename, content (base64), content_type?, content_id? } |
tags | map | no | Up to 10; key ≤ 64 chars, value ≤ 256 |
idempotency_key | string | no | The Idempotency-Key header takes precedence |
Limits: 50 recipients across to+cc+bcc, and 10 MiB encoded.
202 Accepted
{
"id": "5b1e…",
"status": "queued",
"rejected": [{ "address": "[email protected]", "reason": "suppressed" }]
}rejected appears only when non-empty. It is not an error — the rest went
out — but it is the thing to log, because it is how you find out you are mailing
addresses that have unsubscribed or hard-bounced.
Set an idempotency key on anything that costs money or embarrasses you twice. A retry with the same key returns the original message rather than sending again, which is what makes a timeout safe to retry.
Send a batch
Up to 100 distinct messages in one request — worth it from Lagos, where the round trip is most of what makes a loop feel slow.
POST /v1/emails/batch
curl https://api.naijacloud.com/v1/emails/batch \
-H "Authorization: Bearer $NC_API_KEY" \
-H "Content-Type: application/json" \
-d '[
{ "from": "Acme <[email protected]>", "to": ["[email protected]"], "subject": "Receipt", "html": "<p>1</p>", "idempotency_key": "r-1" },
{ "from": "Acme <[email protected]>", "to": ["[email protected]"], "subject": "Receipt", "html": "<p>2</p>", "idempotency_key": "r-2" }
]'A batch is not atomic, on purpose: one malformed message must not discard
the ninety-nine good ones. Each entry comes back as { id, status } or
{ error }, in request order, and the response is 202 whatever the mix — so
walk the array rather than branching on the status code.
Idempotency is per item and yours to supply. We cannot generate one per item, because a retried batch would then get fresh keys and send everything twice.
Read a message back
GET /v1/emails/{id}
curl https://api.naijacloud.com/v1/emails/5b1e… \
-H "Authorization: Bearer $NC_API_KEY"status is lowercase and one of queued, sent, delivered, bounced,
deferred, complained, rejected, failed. Treat it as an open set — a new
status should not crash your integration.
to on a retrieved message is a single address: we write one row per
primary recipient, so a three-recipient send returns the id of the first and
each recipient has its own record.
A message belonging to another workspace answers 404, exactly like one that
never existed. Do not read a 404 as "this id is free".
List and filter
GET /v1/emails?status=bounced&since=2026-08-01&page=1&limit=50
curl "https://api.naijacloud.com/v1/emails?status=bounced&limit=50" \
-H "Authorization: Bearer $NC_API_KEY"Filters: status, to, since, until. Every paginated endpoint returns the
same envelope:
{ "data": [ … ], "page": 1, "limit": 25, "total_count": 431, "has_more": true }page is 1-based and limit is clamped to 100. A non-numeric page or limit
is refused with a 400 rather than silently corrected.
Quotas
GET /v1/limits
curl https://api.naijacloud.com/v1/limits -H "Authorization: Bearer $NC_API_KEY"Returns the static limits, your per-key rate limit and the per-domain daily quota. A new verified domain starts at 200 sends on its first day and the ceiling doubles per full day of clean sending, up to a self-serve maximum of 50,000. Reputation, not paperwork — check this endpoint before a large run rather than discovering the cap mid-send.
Domains and suppressions
These change configuration rather than send correspondence, so they need
EMAIL_SEND and PLATFORM_API on the key. A send-only key gets a 403
naming both.
| Endpoint | Does |
|---|---|
GET /v1/domains | List sending domains |
POST /v1/domains | Add one, and get the DNS records to publish |
GET /v1/domains/{id} | One domain with its records |
POST /v1/domains/{id}/verify | Re-check DNS now |
DELETE /v1/domains/{id} | Remove it |
GET /v1/suppressions | Addresses we will not mail |
POST /v1/suppressions | Add one. reason is never accepted here |
DELETE /v1/suppressions/{address} | Remove one |
On a domain read, each record's verified and problem are null rather than
false — a list does not resolve DNS, only the verify call does. Treat them as
tri-state, or you will report a healthy domain as broken.
Deleting a complaint suppression answers 409, not 403. Someone marked your
mail as spam; re-adding them is not something a retry can fix.
Errors
Standard status codes, with a NestJS body:
{ "statusCode": 403, "message": "not allowed to send from \"[email protected]\". Verify the domain first.", "error": "Forbidden" }message is a string or an array of strings — handle both.
| Status | Meaning | Retry? |
|---|---|---|
401 | Missing, revoked or malformed key | no |
403 | Scope, unverified domain, or a test key on a live send | no |
404 | No such message, or not yours | no |
409 | Conflicts with existing state, e.g. a complaint suppression | no |
422 | The body failed validation | no |
429 | Rate limited — honour Retry-After | yes |
5xx | Ours | yes |
Log the x-request-id response header. It is what support needs to find your
exact send.
SDKs
Five official SDKs wrap all of this, with retries, idempotency and key redaction
already handled — nc-email-node, nc-email-python, nc-email-go,
nc-email-ruby and nc-email-php. All accept an nc_live_ workspace key:
Node
import { Naijamail } from '@naijacloud/email';
const nm = new Naijamail(process.env.NC_API_KEY);
await nm.emails.send({
from: 'Acme <[email protected]>',
to: ['[email protected]'],
subject: 'Your receipt',
html: '<p>Thanks for your order.</p>',
});They read NAIJAMAIL_API_KEY from the environment when no key is passed, and
check the key's shape at construction — so a truncated paste fails at start-up
rather than as a 401 during a customer's checkout.