Authentication
Every request carries a bearer token in the Authorization header. Create one in
NSEND under Settings → API tokens.
# Base URL https://webmail.nsend.net/api/v1 # Every request curl https://webmail.nsend.net/api/v1/me \ -H "Authorization: Bearer nsnd_YOUR_TOKEN_HERE"
A token is as powerful as a password. Depending on its scopes it can read every message in every mailbox on the account and send mail as its owner. Keep it server-side — never ship one in a browser app, mobile binary or public repository. If a token leaks, revoke it in Settings; revocation takes effect on the next request.
The secret is shown once, when you create it. NSEND stores only a hash, so a lost token cannot be recovered — issue a new one. Changing or resetting the account password revokes every token on that account.
Session cookies are deliberately not accepted here. Browsers attach cookies automatically, so a cookie-authenticated API would let any page the user visits drive their mailbox. Requiring an explicit header means a caller must have been given a token on purpose.
Scopes
A token carries only the permissions you tick when creating it. Calling an endpoint outside
them returns 403 insufficient_scope.
| Scope | Grants |
|---|---|
accounts:read | List the mailboxes on the account (addresses and display names — never passwords or server settings) |
mail:read | Read folders, messages, attachments, raw source and search results |
mail:write | Change flags, move and delete messages, create/rename/delete folders |
mail:send | Send email as the token owner |
contacts:read | Read the address book |
settings:read | Read app settings |
settings:write | Change app settings |
Tokens cannot manage other tokens, add or remove mailboxes, or change the account password — those stay behind an interactive sign-in, so a leaked token can't entrench itself.
Errors
Errors use standard HTTP status codes with a JSON body: { "error": "…", "code": "…" }.
| Status | Code | Meaning |
|---|---|---|
| 400 | bad_request | Missing or malformed parameter |
| 401 | no_token | No Authorization: Bearer header |
| 401 | invalid_token | Unknown, expired or revoked token |
| 403 | insufficient_scope | Token lacks the required scope |
| 404 | not_found | No such account, message or attachment |
| 429 | — | Rate limit exceeded |
| 502 | send_failed | The upstream SMTP server rejected the message |
Requesting an account the token's owner does not own returns 404, not
403 — the API does not confirm whether another tenant's account id exists.
Rate limits
300 requests per minute per token. Buckets are per token rather than per IP,
so one busy integration can't exhaust another's budget. Over the limit you get
429; retry after the window resets.
Identity
GET /me
Who this token belongs to and what it may do. Useful to validate a token at setup time.
{
"id": "5f3c…",
"email": "you@example.com",
"scopes": ["accounts:read", "mail:read", "mail:send"]
}
Accounts
GET /accounts accounts:read
Every mailbox linked to the account. Use these ids in the routes below.
{
"accounts": [
{ "id": "a1b2…", "email": "you@example.com", "displayName": "You" }
]
}
Folders
| Endpoint | Scope | Notes |
|---|---|---|
GET /accounts/:id/folders | mail:read | All folders with total and unread counts |
POST /accounts/:id/folders | mail:write | Body {"name":"Receipts"} → 201 |
PATCH /accounts/:id/folders | mail:write | Body {"path":"Receipts","name":"Invoices"} — returns the new path |
DELETE /accounts/:id/folders?path= | mail:write | Deletes the folder and its contents |
A folder is addressed either by its system id (inbox, sent,
drafts, trash, archive, spam) or by its raw
IMAP path. System ids are portable across providers; prefer them where you can.
Messages
IMAP UIDs are unique only within a folder, so every message route takes a
folder (defaulting to inbox).
GET /accounts/:id/messages mail:read
Query: folder, limit (1–200, default 50), query
(server-side search within the folder).
curl "$BASE/accounts/$ACCT/messages?folder=inbox&limit=20" \ -H "Authorization: Bearer $TOKEN" { "folder": "inbox", "messages": [{ "uid": 4821, "folder": "inbox", "from": { "name": "Ada", "email": "ada@example.com" }, "to": [{ "name": "", "email": "you@example.com" }], "subject": "Invoice #4021", "date": "2026-07-21T14:03:11.000Z", "unread": true, "starred": false, "hasAttachments": true, "preview": "Attached is the invoice…", "messageId": "<abc@example.com>" }] }
GET /accounts/:id/messages/:uid mail:read
Full message. html is sanitised for safe rendering; attachments are listed as
metadata only, so the response stays small regardless of payload size.
{
"uid": 4821, "folder": "inbox",
"html": "<p>Attached is the invoice…</p>",
"attachments": [
{ "index": 0, "filename": "invoice.pdf",
"size": 48210, "contentType": "application/pdf", "isImage": false }
]
}
| Endpoint | Scope | Notes |
|---|---|---|
GET /messages/:uid/raw | mail:read | Original RFC 822 source as message/rfc822 |
GET /messages/:uid/attachments/:index | mail:read | Raw attachment bytes with its own content type |
PATCH /messages/:uid | mail:write | Body {"seen":true,"flagged":false} — either or both |
DELETE /messages/:uid | mail:write | Moves to Trash (or expunges if already there) |
POST /messages/move | mail:write | Body {"folder":"inbox","uids":[1,2],"dest":"archive"} |
POST /messages/flags | mail:write | Body {"folder":"inbox","uids":[1,2],"seen":true} — bulk |
GET /accounts/:id/search?q= | mail:read | Searches every folder, not just one |
Sending
POST /accounts/:id/messages/send mail:send
Sends over the mailbox's own SMTP server and appends a copy to Sent. Requires
to plus at least one of text / html.
curl -X POST "$BASE/accounts/$ACCT/messages/send" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "to": ["ada@example.com"], "cc": [], "subject": "Re: Invoice #4021", "html": "<p>Paid — thanks!</p>", "inReplyTo": "<abc@example.com>", "trackReceipt": false }' { "ok": true, "messageId": "<xyz@nsend>", "appendedToSent": true }
Attachments go in attachments as
{"filename","contentType","contentBase64"}. Set trackReceipt: true to
embed a read-receipt pixel. An SMTP rejection surfaces as 502 send_failed.
Contacts
GET /accounts/:id/contacts contacts:read
The address book for a mailbox — manually added entries plus addresses harvested from
history. Returns { "contacts": [{ "email", "name", "manual" }] }.
Settings
| Endpoint | Scope | Notes |
|---|---|---|
GET /settings | settings:read | The account's app settings object |
PATCH /settings | settings:write | Shallow merge — send only the keys you're changing |
Real-time stream
GET /stream mail:read
A Server-Sent Events stream that pushes new mail as it arrives, so you don't have to poll. Holding the connection open keeps IMAP IDLE watchers running for the account.
# events event: ready # connection established data: {} event: mail data: {"accountId":"a1b2…","count":1,"from":"Ada","subject":"Invoice #4021"} # a ": keep-alive" comment arrives every 25s
The event announces that mail arrived, not its contents — fetch the message with
GET /messages when you receive one. EventSource in the browser cannot
set headers, so use an SSE client that can send Authorization (and remember a token
does not belong in a browser anyway).
Versioning
Everything above lives under /api/v1. Additive changes (new endpoints, new
response fields) can land in v1; anything that would break an integration ships as
/api/v2.