Docs
guides · sending

Send email

One endpoint sends every transactional message — receipts, magic links, alerts. Give it a from, at least one recipient, and a body. Everything else is optional.

POST/v1/emails

A send is a single JSON request. The minimum is a from address, a to array, a subject, and a body — either html, text, or both. A successful call returns 202 Accepted with a message id and a status of queued; delivery happens asynchronously and surfaces over webhooks and metrics.

curl https://api.drin.run/v1/emails \
  -H "Authorization: Bearer $DRIN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "from": { "email": "hello@acme.com", "name": "Acme" },
    "to": [{ "email": "customer@example.com" }],
    "subject": "Welcome aboard",
    "html": "<h1>You\u2019re in</h1><p>Thanks for signing up.</p>",
    "text": "You\u2019re in. Thanks for signing up."
  }'
202 Accepted
{
  "id": "msg_8f2c1a7e",
  "status": "queued"
}
Test modeBefore you verify a domain, send from the shared onboarding domain — it can only deliver to your own address. To email anyone from your own brand, verify a domain (DKIM + SPF + DMARC, guided), then use it as your from.

01 Recipients

Every address is an object — { email } with an optional name for the display name. to, cc,bcc, and replyTo are all arrays, so you can pass several at once.

Multiple recipients
await drin.emails.send({
  from: { email: "billing@acme.com", name: "Acme Billing" },
  to: [
    { email: "ada@example.com", name: "Ada Lovelace" },
    { email: "grace@example.com" },
  ],
  cc: [{ email: "records@acme.com" }],
  bcc: [{ email: "archive@acme.com" }],
  replyTo: [{ email: "support@acme.com", name: "Acme Support" }],
  subject: "Your March invoice",
  html: "<p>Invoice attached.</p>",
});
  • to — primary recipients. At least one is required.
  • cc — carbon copies, visible to everyone.
  • bcc — blind copies, hidden from other recipients.
  • replyTo — where replies should go when it differs from from (e.g. send from noreply@, reply to support@).
Pick a verified domain in codeThe SDK's domains.listVerified() returns every domain you can send from, so you never hard-code a from.
From a verified domain
const [domain] = await drin.domains.listVerified();

await drin.emails.send({
  from: { email: `hello@${domain.domain}` },
  to: [{ email: "customer@example.com" }],
  subject: "Hi",
  html: "<p>Sent from a domain you control.</p>",
});

02 Subject and body

The subject is required for an inline send. For the body, provide html, text, or both. Sending both is recommended: clients that can't render HTML — and many spam filters — fall back to the plain-text part, and a good text alternative improves deliverability.

  • html — the rich body. Inline your CSS; most mail clients strip <style> blocks and external stylesheets.
  • text — the plain-text alternative. If you only send HTML, recipients on text-only clients see nothing.
Templates instead of inline bodiesTo reuse a body across sends, store it once and send by templateId with data for the merge variables. You may not combine templateId with inline html/text. See Templates.

03 Attachments

Pass attachments as an array. Each item needs a filename, the base64-encoded content, and a contentType. Encode the raw bytes — don't wrap them in a data URL.

import { readFileSync } from "node:fs";

await drin.emails.send({
  from: { email: "billing@acme.com" },
  to: [{ email: "customer@example.com" }],
  subject: "Your receipt",
  html: "<p>Your receipt is attached.</p>",
  attachments: [
    {
      filename: "receipt.pdf",
      content: readFileSync("./receipt.pdf").toString("base64"),
      contentType: "application/pdf",
    },
  ],
});
Keep attachments smallThe whole request — including base64 attachments, which inflate the raw bytes by ~33% — must fit the API's body limit. For large or shared files, send a link rather than the bytes.

04 Custom headers and tags

headers is a flat string → string map merged into the outgoing message — useful for List-Unsubscribe, your own reference IDs, or any header your downstream systems expect. tags is a separate string → string map stored on the message for filtering and analytics; tags travel on the delivery events you receive over webhooks, but are never added to the email itself.

Headers + tags
await drin.emails.send({
  from: { email: "hello@acme.com" },
  to: [{ email: "customer@example.com" }],
  subject: "Password reset",
  html: "<p>Reset link inside.</p>",
  headers: {
    "X-Entity-Ref-ID": "reset-9a3f",
    "List-Unsubscribe": "<https://acme.com/unsub?u=42>",
  },
  tags: {
    category: "transactional",
    template: "password-reset",
  },
});

05 Request fields

fromobjectRequired
The sender. { email, name? }. The email must belong to a verified domain (or the onboarding domain in test mode).
toobject[]Required
Primary recipients, { email, name? }. At least one.
subjectstringRequired
The subject line. Optional only when templateId supplies it.
htmlstringOptional
The HTML body. Provide html, text, or both.
textstringOptional
The plain-text body / alternative part.
ccobject[]Optional
Carbon-copy recipients.
bccobject[]Optional
Blind-carbon-copy recipients.
replyToobject[]Optional
Where replies are routed when different from from.
attachmentsobject[]Optional
Files to attach. Each is { filename, content, contentType }, where content is base64-encoded bytes.
headersobjectOptional
A string → string map of custom headers added to the message.
tagsobjectOptional
A string → string map stored on the message for filtering and analytics.
templateIdstringOptional
Send a stored template by id or slug instead of inline html/text. See Templates.
dataobjectOptional
Merge variables for templateId.
scheduledAtstringOptional
ISO-8601 timestamp to send the message in the future. See Batch & scheduled.
Make sends safe to retryPass an Idempotency-Key header so a retried request after a network blip can't double-send. See Idempotency & retries.

06 Next