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.
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. Each record shows the host to type into your DNS zone and its full name, and if the zone is on Naijacloud DNS, Add records for me publishes them in one step. 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
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 <receipts@acme.ng>",
"to": ["ada@example.com"],
"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 <a@b.com>" or "a@b.com". 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 | yes | Must not be empty |
html, text | string | one of | At least one is required; 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 of message (html,
text and attachment bytes).
{
"id": "5b1e…",
"status": "queued",
"sandbox": false,
"rejected": [{ "address": "old@example.com", "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.
Test without sending
An nmail_test_ key is sandboxed: choose Test when you create a key under
Email → API keys. A send is accepted,
recorded and given a real id and a final status, but never reaches a mail
server, so staging and CI can run the whole flow without mailing anyone.
- Send from a domain you have added, or from any address at
test.mail.naijacloud.dev. - Send to
delivered@,bounced@orcomplained@test.mail.naijacloud.devto get that outcome; any other recipient reads as delivered. - A message sent this way has
"sandbox": truein the send response and when you read it back, and it is never billed.statusis the simulated outcome, so checksandboxbefore treatingdeliveredas a delivery. - In the dashboard's Emails log it reads Test · not sent (or Test · bounced), never Delivered.
- A test key can read domains and suppressions but not change them.
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.
curl https://api.naijacloud.com/v1/emails/batch \
-H "Authorization: Bearer $NC_API_KEY" \
-H "Content-Type: application/json" \
-d '[
{ "from": "Acme <receipts@acme.ng>", "to": ["a@example.com"], "subject": "Receipt", "html": "<p>1</p>", "idempotency_key": "r-1" },
{ "from": "Acme <receipts@acme.ng>", "to": ["b@example.com"], "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
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
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
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 |
Each record has host, relative to the sending domain (@ is the sending
domain itself), and fqdn, the full name it must exist at. If you edit the
parent zone, write the host relative to that zone: for send.acme.com in the
acme.com zone, DKIM's nm._domainkey becomes nm._domainkey.send. The MX
goes on the sending domain itself, and is never asked for on an apex domain,
where it would take a share of your real mail.
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 \"x@y.com\". 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 |
400 | The body failed validation; message says which field | no |
403 | Scope, unverified domain, quota, or a test key changing configuration | no |
404 | No such message, or not yours | no |
409 | Conflicts with existing state, e.g. a complaint suppression | no |
413 | The request body is too large | 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 sending and reading a message back, with retries,
idempotency and key redaction already handled — nc-email-node,
nc-email-python, nc-email-go, nc-email-ruby and nc-email-php. Batch,
list, limits, domains and suppressions are REST-only for now. All accept an
nc_live_ workspace key:
import { Naijamail } from '@naijacloud/email';
const nm = new Naijamail(process.env.NC_API_KEY);
await nm.emails.send({
from: 'Acme <receipts@acme.ng>',
to: ['ada@example.com'],
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.