Email API — sending
Last updated 2026-08-26 (CR-081) — adopter-facing guide for sending email through
https://doable.services.
Send transactional email — notifications, magic links, confirmations — to your users through the doable.services Email API, authenticated with an API key. No SMTP setup, no mail server: one HTTPS request per message.
If you just need to get going, jump to Quick start.
Overview
| Item | Value |
|---|---|
| Base URL | https://doable.services |
| Endpoint | POST /api/v1/email/send |
| Auth | Authorization: Bearer <YOUR_API_KEY> |
| Content type | application/json |
| Sender | Fixed to <your-route>@mg.doable.services (see Your sender) |
You'll be issued an API key with email-sending permission. Keep it secret (server-side only — never ship it in browser/JS that users can view). Anyone with the key can send as your sender address.
Quick start
curl -X POST https://doable.services/api/v1/email/send \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "user@example.com",
"subject": "Your sign-in link",
"html": "<p>Hi! <a href=\"https://example.com/login?t=…\">Click to sign in</a>.</p>"
}'
Your message is sent from your-route@mg.doable.services automatically — you
don't set the sender. Success returns the queued message id:
{ "success": true, "id": "<20260613101319.abc@mg.doable.services>" }
Your sender
On the shared mg.doable.services domain, the sender is fixed to your route:
<your-route>@mg.doable.services
You do not set or change it — there's no from field to fill in, and any
from you send is ignored. This keeps the shared domain's reputation clean and
unambiguous across everyone using it.
Want your own sender (your brand, a custom address, your own domain)? You
need a verified sending domain attached to your route. Contact the operator
to set one up; once attached, you'll be able to choose your from address on
that domain.
Replies: by default mail is sent from
mg.doable.services, which isn't a monitored inbox. Setreply_toon each message so replies reach a real address, and/or ask the operator to enable inbound forwarding for your route.
Request
POST /api/v1/email/send with a JSON body:
| Field | Required | Description |
|---|---|---|
to |
yes | Recipient email address. |
subject |
yes | Subject line. |
html |
yes* | HTML body. |
text |
yes* | Plain-text body. |
route |
no | Route slug to send on behalf of. Only needed if your key serves more than one route; with a single-route key it's inferred. |
from_name |
no | Display name shown beside your sender address, e.g. Sonetel Workation Goa. The address itself does not change. |
reply_to |
no | Address replies should go to. Strongly recommended — the shared sending domain is not a monitored inbox. |
list_unsubscribe |
no | Bracketed <mailto:…> or <https://…>. Sets List-Unsubscribe and one-click List-Unsubscribe-Post. |
* Provide html, text, or both. At least one is required. Send both where
you can — a text-only or HTML-only message scores worse at the large receivers.
If you send only html, a text alternative is derived for you. The sender
address is set for you (see Your sender) — there is no from
field on the shared domain.
Display name, reply address, unsubscribe (CR-081)
{
"route": "your-route",
"to": "user@example.com",
"from_name": "Your Project",
"reply_to": "hello@yourcompany.com",
"list_unsubscribe": "<mailto:hello@yourcompany.com?subject=unsubscribe>",
"subject": "...",
"html": "...",
"text": "..."
}
Your mail then arrives as Your Project <your-route@mg.doable.services> instead
of a bare machine address, and recipients have somewhere to reply.
from_name is a name, not an address: one containing @, <, > or a line
break is rejected with 400. A display name carrying someone else's address is
spoofing — most mail clients show only the display name — so the API refuses it
rather than quietly rewriting it.
Response & status codes
| Status | Meaning |
|---|---|
200 |
Queued. Body: {"success": true, "id": "<…>"}. |
400 |
Missing/invalid field (to, subject, body), or route needed but not given. |
401 |
Missing or invalid API key. |
403 |
Key lacks send permission, or not authorised for the given route. |
429 |
Rate limit exceeded (see below). |
502 |
Upstream mail provider error — safe to retry with backoff. |
503 |
Email sending is currently disabled. |
Errors return {"success": false, "error": "…"} (or {"error": "…"}).
Rate limits
| Window | Default limit (per key) |
|---|---|
| Per hour | 100 |
| Per day | 1000 |
Exceeding a limit returns 429. Need more for a launch or campaign? Ask the
operator to raise your key's limits.
Examples
Node.js (fetch)
const res = await fetch("https://doable.services/api/v1/email/send", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.DOABLE_EMAIL_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
to: "user@example.com",
subject: "Your sign-in link",
html: "<p>Click to sign in: <a href='…'>Sign in</a></p>",
}),
});
const data = await res.json();
if (!data.success) throw new Error(data.error);
Python (requests)
import os, requests
r = requests.post(
"https://doable.services/api/v1/email/send",
headers={"Authorization": f"Bearer {os.environ['DOABLE_EMAIL_KEY']}"},
json={
"to": "user@example.com",
"subject": "Your sign-in link",
"html": "<p>Click to sign in: <a href='…'>Sign in</a></p>",
},
timeout=15,
)
data = r.json()
if not data.get("success"):
raise RuntimeError(data.get("error"))
Best practices
- One recipient per request. Send individual messages rather than bulk blasts — better deliverability and clearer logs.
- Keep the key server-side. Put it in an environment variable / secret store. If it leaks, ask for a rotation.
- Handle
429/502with exponential backoff and retry; treat4xx(except429) as permanent — fix the request. - Plain-text matters. Include a
textpart alongsidehtmlfor the best deliverability.
Receiving mail (inbox)
If the operator enables inbound storage for your route, inbound mail is
stored and retrievable with a key that has read permission
(can_read_email). On the current shared-domain configuration your inbox
address is <your-route>@mg.doable.services — the route is the local part.
(An operator who moves you to your own sending domain will tell you the address;
older revisions of this guide described a per-route subdomain scheme that is not
what is deployed.) (Inbound can also be forwarded
to an inbox — ask the operator; the two are independent.)
List messages — GET /api/v1/email/inbox
Query params (all optional): route (slug, if your key serves several),
unread=1, limit (default 50, max 200).
curl -H "Authorization: Bearer YOUR_API_KEY" \
"https://doable.services/api/v1/email/inbox?unread=1&limit=20"
{
"count": 1,
"messages": [
{ "id": 42, "received_at": "2026-06-13T10:12:00Z", "route": "your-route",
"from": "user@example.com", "recipient": "hi@your-route.doable.services",
"subject": "Hello", "is_read": false, "has_html": true }
]
}
Fetch one — GET /api/v1/email/inbox/<id>
Returns the full message (text + html) and marks it read.
curl -H "Authorization: Bearer YOUR_API_KEY" \
https://doable.services/api/v1/email/inbox/42
Delete — DELETE /api/v1/email/inbox/<id>
curl -X DELETE -H "Authorization: Bearer YOUR_API_KEY" \
https://doable.services/api/v1/email/inbox/42
Stored messages are pruned automatically after a retention window (default 30 days). A key only sees mail for routes it's authorised on.
What this API does not do (yet)
- Custom senders require a verified sending domain attached to your route (contact the operator). On the shared domain the sender is fixed.
- Attachments and templates/merge variables are not yet supported.
Need one of these? Contact the operator.