Naijacloud
Docs

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:

  1. 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.
  2. 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
bash
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" } }'
FieldTypeRequiredNotes
fromstringyes"Name <a@b.com>" or "a@b.com". The domain must be verified
tostring or string[]yesAt least one address
cc, bccstring or string[]no
reply_tostring or string[]noreplyTo is accepted too
subjectstringyesMust not be empty
html, textstringone ofAt least one is required; send both where you can
headersmapnoUp to 25. From, To, Cc, Bcc, Subject, DKIM-Signature and Received are refused
attachmentsarrayno{ filename, content (base64), content_type?, content_id? }
tagsmapnoUp to 10; key ≤ 64 chars, value ≤ 256
idempotency_keystringnoThe Idempotency-Key header takes precedence

Limits: 50 recipients across to+cc+bcc, and 10 MiB of message (html, text and attachment bytes).

202 Accepted
json
{ "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@ or complained@test.mail.naijacloud.dev to get that outcome; any other recipient reads as delivered.
  • A message sent this way has "sandbox": true in the send response and when you read it back, and it is never billed. status is the simulated outcome, so check sandbox before treating delivered as 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.

POST /v1/emails/batch
bash
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

GET /v1/emails/{id}
bash
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
bash
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:

json
json
{ "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
bash
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.

EndpointDoes
GET /v1/domainsList sending domains
POST /v1/domainsAdd one, and get the DNS records to publish
GET /v1/domains/{id}One domain with its records
POST /v1/domains/{id}/verifyRe-check DNS now
DELETE /v1/domains/{id}Remove it
GET /v1/suppressionsAddresses we will not mail
POST /v1/suppressionsAdd 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:

json
json
{ "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.

StatusMeaningRetry?
401Missing, revoked or malformed keyno
400The body failed validation; message says which fieldno
403Scope, unverified domain, quota, or a test key changing configurationno
404No such message, or not yoursno
409Conflicts with existing state, e.g. a complaint suppressionno
413The request body is too largeno
429Rate limited — honour Retry-Afteryes
5xxOursyes

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:

Node
js
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.