← Back to home

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. Set reply_to on 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


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)

Need one of these? Contact the operator.