DenSend API
One HTTPS endpoint for sending email, and a webhook for what happens next. There are official libraries for eight languages, and every example on this page is also written out as a plain HTTP request, so anything that can reach the internet can use it.
- Base URL
- https://api.densend.com/v1
- Auth
- Bearer token in the Authorization header
- Format
- JSON in, JSON out
- Sending domain
- Must be verified before you can send from it
Quickstart
- 1Add and verify a domain under Domains. Publish the DNS records it gives you and it verifies itself, usually within a minute.
- 2Create an API key under API Keys. It is shown once, so copy it then.
- 3Install the package for your language, then send your first email with the call below. Switch language with the tabs.
import { DenSend } from "@densend/node";
const densend = new DenSend(process.env.DENSEND_API_KEY);
const { id } = await densend.emails.send({
from: "You <[email protected]>",
to: "[email protected]",
subject: "Hello from DenSend",
html: "<p>Your order has shipped.</p>",
});
console.log(id);Requires Node 18 or newer.
A successful send returns 202 with the id of each message created. One id per recipient, because a bounce belongs to exactly one address.
Response
{
"id": "8f2b1c40-9a3e-4a1b-9f0c-2d1e5a7b3c4d",
"status": "queued",
"ids": [
"8f2b1c40-9a3e-4a1b-9f0c-2d1e5a7b3c4d"
]
}Authentication
Every request carries an API key as a bearer token. Keys start with mf_denis_ and are scoped to one workspace.
Authorization: Bearer mf_denis_xxxxxxxxxxxxxxxx
Keep keys on your server
A key can send as any verified domain in its workspace. Never put one in frontend code, a mobile app, or a public repository. If one leaks, revoke it under API Keys and create another.
Keys carry a scope: full, send or read. A read key cannot send.
Send an email
| from | string | required | Verified sender. `Name <[email protected]>` also works. |
| to | string or array | required | One address, or several. |
| subject | string | optional | Defaults to (no subject). |
| html | string | optional | HTML body. |
| text | string | optional | Plain text body. Send both where you can. |
| cc | array | optional | Copied recipients. |
| bcc | array | optional | Blind copied recipients. |
| reply_to | string | optional | Where replies should go. |
| tags | array | optional | Your own labels, returned on the message. |
| scheduled_at | string | optional | ISO 8601. See scheduling. |
| attachments | array | optional | 7MB total, after decoding. See attachments. |
const { ids } = await densend.emails.send({
from: "You <[email protected]>",
to: ["[email protected]", "[email protected]"],
cc: ["[email protected]"],
bcc: ["[email protected]"],
replyTo: "[email protected]",
subject: "Your receipt",
html: "<p>Thanks for your order.</p>",
text: "Thanks for your order.",
});
// One id per recipient, because a bounce belongs to exactly one address.
console.log(ids);Requires Node 18 or newer.
Scheduling
Pass scheduled_at as an ISO 8601 timestamp and the message waits until then. Times are UTC unless you say otherwise.
await densend.emails.send({
from: "You <[email protected]>",
to: "[email protected]",
subject: "A reminder",
text: "This was scheduled.",
scheduledAt: new Date("2026-09-01T09:00:00Z"),
});Requires Node 18 or newer.
Attachments
Each attachment is filename, base64-encoded content, and an optional content_type (defaults to application/octet-stream). 7MB total across all attachments combined, after decoding - not the size of the base64 text itself. Going over returns a 422 naming the actual size, so you find out before the request succeeds, not after.
import { readFileSync } from "node:fs";
await densend.emails.send({
from: "You <[email protected]>",
to: "[email protected]",
subject: "Your invoice",
text: "Attached.",
attachments: [
{
filename: "invoice.pdf",
content: readFileSync("./invoice.pdf"), // a Buffer is encoded for you
contentType: "application/pdf",
},
],
});Requires Node 18 or newer.
Retrieve an email
Returns the message and its timeline: queued, sent, delivered, bounced, complained.
const email = await densend.emails.get("8f2b1c40-9a3e-4a1b-9f0c-2d1e5a7b3c4d");
console.log(email.status, email.opens, email.clicks);Requires Node 18 or newer.
Response
{
"id": "8f2b1c40-9a3e-4a1b-9f0c-2d1e5a7b3c4d",
"to": "[email protected]",
"from_addr": "[email protected]",
"subject": "Hello",
"status": "delivered",
"direction": "outbound",
"opens": 0,
"clicks": 0,
"created_at": "2026-08-21T09:14:22Z",
"events": [
{
"type": "delivered",
"detail": null,
"ts": "2026-08-21T09:14:25Z"
}
]
}List emails
Newest first. Use limit and offset to page, and direction to filter. The total sits in the X-Total-Count header, so you can tell how much is left.
const emails = await densend.emails.list({ limit: 20 });Requires Node 18 or newer.
SMTP
Anything that speaks SMTP can send through DenSend without touching the API. Useful for WordPress, Django, Rails or any framework with mail already wired up.
- Host
- smtp.densend.com
- Port
- 587 with STARTTLS
- Username
- densend
- Password
- your API key
The password is the API key itself, so it needs the send or full scope. Your exact settings are on the SMTP page in the dashboard.
Errors
Failures come back as JSON with a detail field written to be shown to a person, not parsed by a machine.
{
"detail": "acme.com is not verified yet. Publish its DNS records, then verify it."
}| 401 | Unauthorized | Missing or invalid API key. | |
| 402 | Payment required | Plan limit reached, or the subscription lapsed. | |
| 403 | Forbidden | Sending from a domain you have not verified. | |
| 422 | Unprocessable | Something in the body is wrong. The detail says what. | |
| 429 | Too many requests | Slow down. Retry after the header says. |
Rate limits
Limits come from your plan rather than from the API: a monthly allowance, and on some plans a daily one. Going past either returns 402 with a message saying which limit you hit.
Extra emails bought on top of a plan are used only once the monthly allowance is gone, and they do not expire when the period rolls over.
