Install · The API surface · Client options · Errors · Retries · Webhooks · Security
naijacloud-email
The official Ruby SDK for Naijamail, the transactional email API of Naija Cloud.
Zero runtime dependencies. Standard-library net/http only.
require "naijacloud/email"
nm = NaijaCloud::Email::Client.new # reads NAIJAMAIL_API_KEY
sent = nm.emails.send_email(
from: "Acme <hello@acme.com>",
to: "customer@example.com",
subject: "Your receipt",
html: "<p>Thanks for your order.</p>",
)
puts sent.id # => "5b1e..."
puts sent.status # => "queued"
email = nm.emails.get(sent.id)
puts email.status # => "delivered"Install
gem "naijacloud-email"Ruby 2.7 or newer.
The API surface
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_email(...) |
POST /v1/emails, returns SendEmailResponse
|
nm.emails.create(...) |
alias of send_email
|
nm.emails.get(id) |
GET /v1/emails/{id}, returns Email
|
NaijaCloud::Email::Webhooks.verify(...) |
verifies a signed webhook delivery |
Email#created_at and #delivered_at are the ISO-8601 Strings the server
sent (delivered_at is nil until delivery); #created_at_time and
#delivered_at_time parse them into Time on demand. Other SDKs surface a
native date type here — that difference is deliberate, not a bug.
send_email, not send: send is Object#send, and shadowing it on a resource
object means anything that dispatches by name against it — including some mocking
libraries — tries to mail a message instead.
Both call styles work, on every supported Ruby:
nm.emails.send_email(from: "...", to: "...", subject: "Hi")
nm.emails.send_email({ from: "...", to: "...", subject: "Hi" })Send options
| Option | Type | Notes |
|---|---|---|
from: |
String |
required. "Name <a@b.com>" or a bare address. The domain must be verified for your team. |
to: |
String or Array | required. At least one. |
cc:, bcc:
|
String or Array | |
reply_to: |
String or Array | Sent on the wire as reply_to. |
subject: |
String | Always sent; defaults to "". |
html:, text:
|
String | |
headers: |
Hash | At most 25. From, To, Cc, Bcc, Subject, DKIM-Signature and Received are refused, matched case-insensitively on the name with surrounding whitespace trimmed (" From" is refused too). |
attachments: |
Array of Hashes |
{ filename:, content:, content_type:, content_id: }. content is a String of raw bytes; it must not be empty. |
tags: |
Hash | At most 10, key ≤ 64 chars, value ≤ 256. |
idempotency_key: |
String | Optional; one is generated per call if you do not pass one, or pass an empty one. At most 255 bytes of UTF-8. Sent as the Idempotency-Key header only. |
An unknown option raises ValidationError rather than being dropped, so
htlm: fails on your machine instead of sending a blank email to a customer.
Attachments
Pass bytes, not a path, and do not base64-encode them yourself:
nm.emails.send_email(
from: "Acme <billing@acme.com>",
to: "customer@example.com",
subject: "Invoice #1024",
html: "<p>Attached.</p>",
attachments: [
{ filename: "invoice-1024.pdf",
content: File.binread("invoice-1024.pdf"), # you read the file, not us
content_type: "application/pdf" },
],
)content is always taken as raw bytes — in Ruby the String is the byte type
(File.binread, IO#read in binary mode). It is never interpreted as base64: a
String you have already base64-encoded is sent as those characters, encoded a
second time. (The SDKs for languages with a separate byte type — Node, Python,
Go — read a text string as base64; Ruby and PHP cannot tell the two apart, so
they do not try.) An empty attachment is refused locally, as the server would
refuse it.
The SDK never opens a file on your behalf. An SDK that reads whatever path it is handed becomes a local-file-disclosure primitive the moment a web handler passes user input into it — so you read your own file and hand over the bytes.
Rejected recipients
A 202 can still name recipients we refused (the suppression list). It is not an
error: the rest of the message went.
sent = nm.emails.send_email(...)
sent.rejected.each { |r| puts "#{r.address}: #{r.reason}" }rejected is always an array, never nil, even though the server omits the key
when it is empty.
Statuses
queued, sent, delivered, bounced, deferred, complained, rejected,
failed — as NaijaCloud::Email::MessageStatus::DELIVERED and so on. A status
we have not seen before comes through as a plain String rather than raising, so a
new server status does not break an installed gem:
NaijaCloud::Email::MessageStatus.known?(email.status)Delivery is not a state machine. A message can go delivered and then
complained, and providers deliver events out of order often enough that no
client should assume otherwise.
Client options
nm = NaijaCloud::Email::Client.new(
api_key: ENV["NAIJAMAIL_API_KEY"], # default: ENV["NAIJAMAIL_API_KEY"]
base_url: nil, # default: ENV["NAIJAMAIL_BASE_URL"] or https://api.naijacloud.com
timeout: 30, # seconds, per attempt; must be > 0
max_retries: 2, # 3 attempts in total; 0 to 10
user_agent_suffix: "acme-billing/2.1",
)-
timeoutis a deadline on the whole attempt — connect, send and reading the entire response — not a per-socket-read timeout, so a server trickling a byte at a time cannot hold a request open past it. Each retry gets a fresh one. - A blank
NAIJAMAIL_BASE_URL(set but empty) counts as unset. - A
base_urlwith a query string or fragment is refused: every path the SDK appends would land after it. - The key is trimmed of surrounding whitespace (a trailing newline from a secrets file is common) before it is checked.
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 so — "this is a
personal access token (nc_pat_…), which cannot send mail; use a mail API key
(nmail_live_… or nmail_test_…) or a workspace API key with the Email send scope
(nc_live_…)".
Everything lives on the instance. There is no global configuration, so two clients holding two teams' keys can run in one process without one borrowing the other's credential.
A client is safe to share across threads: each request opens its own connection and the client keeps no per-request state.
Errors
Every failure is a NaijaCloud::Email::Error, so one rescue covers the lot:
begin
nm.emails.send_email(from: "...", to: "...", subject: "Hi", html: "<p>Hi</p>")
rescue NaijaCloud::Email::PermissionError => e
# Unverified domain, a key without the right scope, or a quota.
warn "#{e.message} (request #{e.request_id})"
rescue NaijaCloud::Email::RateLimitError => e
warn "rate limited, retry after #{e.retry_after}s"
rescue NaijaCloud::Email::Error => e
warn "#{e.class}: #{e.message} (HTTP #{e.status_code})"
end| HTTP | Class | Retried |
|---|---|---|
| 400 |
ValidationError (NotFoundError when the message is message not found) |
no |
| 401 | AuthenticationError |
no |
| 403 | PermissionError |
no |
| 404 | NotFoundError |
no |
| 408 | TimeoutError |
yes |
| 409 | ConflictError |
no |
| 413, 422 | ValidationError |
no |
| any other 4xx (405, 415, 451…) | ValidationError |
no |
| 429 |
RateLimitError (#retry_after) |
yes |
| 5xx | ServerError |
yes |
| 3xx |
ServerError ("unexpected redirect") |
no |
2xx that is not a JSON object, or a send response with no id
|
ServerError ("malformed response") |
no |
| socket / DNS / TLS | ConnectionError |
yes |
| client-side deadline | TimeoutError |
yes |
| bad input, caught locally |
ValidationError, status_code == 0
|
n/a |
Every error carries message, status_code, error_label (the server's short
label), request_id (from x-request-id), the response text exactly as received
(raw_body, also available as body) and that text parsed as JSON
(parsed_body, nil when it was not JSON). Quote the request_id in a support
ticket.
A 400 for an id that does not exist is a known control-plane quirk — the
retrieve endpoint raises BadRequestException('message not found') instead of a
404. The SDK maps that one case to NotFoundError, so your code keeps working
when the server is fixed.
Retries
Three attempts by default, with full-jitter exponential backoff — base 500ms,
cap 8s — retried only on 429, 408, 5xx, and connection or timeout
failures. A Retry-After header (integer seconds or an HTTP date) on any
retried response — a 429 or a 503 alike — overrides the computed backoff and
is clamped to 60 seconds; RateLimitError#retry_after reports the clamped value.
A 403 on an unverified domain is never retried. It will not become verified
between two attempts, and retrying only burns your rate limit.
Retrying a POST is safe because the SDK generates a UUIDv4 once per
send_email call and sends it as Idempotency-Key on every attempt of that
call. Without it, a timeout followed by a retry mails your customer twice — you
cannot tell "never arrived" from "arrived, response lost". Pass your own
idempotency_key: (derived from an order id, say) and it is used verbatim and
never regenerated.
Security
The full list is in SECURITY.md. In short:
-
HTTPS is enforced at construction. A plaintext
base_urlis refused unless the host islocalhost,127.0.0.1or::1. -
Redirects are never followed. Following one would re-send your
Authorizationheader to whatever host the response named. -
The key is never printed.
inspect,to_sand any dump of the client's instance variables shownmail_live_***. The client does not even keep the key as one of its own instance variables. There is no verbose mode, because a verbose mode is a way to print anAuthorizationheader. -
Header injection is rejected locally — a
\r,\nor NUL infrom, any address,subject, a custom header name or value, or an attachment filename,content_typeorcontent_id. -
Limits are checked before the round trip: 50 recipients, 25 headers, 10
tags, and 10 MiB of message — measured as the server measures it: the UTF-8
bytes of
htmlandtextplus the raw (not base64) attachment bytes. - Webhook signatures are compared in constant time.
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.
# Rails
class NaijamailWebhooksController < ApplicationController
skip_before_action :verify_authenticity_token
def create
event = NaijaCloud::Email::Webhooks.verify(
request.raw_post, # the RAW body, not params
request.headers["NC-Signature"],
ENV.fetch("NAIJAMAIL_WEBHOOK_SECRET"),
)
ProcessEmailEvent.perform_later(event.type, event.email_id)
head :ok
rescue NaijaCloud::Email::WebhookVerificationError
head :bad_request
end
endPass the raw bytes. A parsed-and-re-serialized body produces different bytes than
the ones that were signed (key order, unicode escaping, whitespace), the
signature then fails for every legitimate delivery, and the usual "fix" for that
is to stop verifying. Webhooks.verify refuses a Hash outright for this reason.
Header format: NC-Signature: t=1756468800,v1=<hex sha256 hmac>. The signed
payload is "<t>.<raw body>", HMAC-SHA256 with the endpoint secret, hex
lowercase (the verifier accepts either case). The default replay tolerance is 300
seconds (tolerance:); 0 is strict (only the current second passes), and a
negative or non-numeric tolerance raises ValidationError. t must be 1–12
ASCII digits. A payload that verifies but is not a JSON object (an array, say)
raises WebhookVerificationError. Several v1= values may appear at once during
a secret rotation; any match is accepted.
Local development against a dev control plane
nm = NaijaCloud::Email::Client.new(
api_key: ENV["NAIJAMAIL_API_KEY"],
base_url: "http://localhost:3000",
)Contributing
See CONTRIBUTING.md. The test suite runs offline against a
mock HTTP server on 127.0.0.1; it makes no outbound connection.
License
MIT. Copyright (c) 2026 Naija Cloud.