API Reference

Send email

Send a single transactional email through your workspace's verified sending domain. The send is logged and the email's ID is returned. To send many emails in one request, use Batch send; to read back a send's delivery status, use the Emails endpoint.

Endpoint

POST/api/v1/send

Request headers

HeaderValue
AuthorizationBearer ps_live_YOUR_API_KEY — required
Content-Typeapplication/json — required

Request body

JSON object with the following fields:

FieldTypeRequiredDescription
tostringrequiredRecipient email address.
subjectstringrequiredEmail subject line.
htmlstringrequiredHTML body of the email.
sender_idstringoptionalID of a sender configured in your workspace (Settings → Senders). Takes precedence over from. Must belong to your workspace and be on a verified domain, or the request is rejected (404 sender_not_found / 403 sender_not_verified).
fromstringoptionalSender address on one of your verified domains (validated; 403 sender_not_verified otherwise). Ignored when sender_id is given. Omit both to use your workspace default sender.
reply_tostring | string[]optionalReply-To address — a single email address or an array of addresses — set on the message. Precedence: this field, then the resolved sender's default reply_to (set per sender in Settings → Senders), then none. An invalid address is rejected as invalid_field (param reply_to) and nothing is sent.
attachmentsobject[]optionalUp to 20 attachments, each inline ({ filename, content (base64), content_type? }) or hosted ({ filename, url, content_type? }). See Attachments below.
tracking"full" | "essential" | "off"optionalPer-message open-tracking override. Omit to inherit the workspace default (Settings → Workspace). "full" = precise opens + clicks; "essential" = day-only opens, clicks unaffected; "off" = no open/click tracking. "off" is gated — until your account is provisioned for it, requesting "off" returns 503 service_unavailable and nothing is sent. See the Open tracking guide.

Example request body:

{
  "to": "recipient@example.com",
  "subject": "Your order has shipped!",
  "html": "<h1>It's on the way</h1><p>Track your order at...</p>",
  "from": "orders@yourdomain.com",
  "reply_to": "support@yourdomain.com"
}

Reply-To. Pass reply_to as a single email or an array of emails to set the message's Reply-To. Precedence is request reply_to → the resolved sender's default reply_to → none; you can set a per-sender default under Settings → Senders. An invalid address fails with invalid_field (param: "reply_to") and the email is not sent.

Attachments

Add up to 20 attachments. Each is either inline (base64 content) or hosted (a url we fetch at send time and discard — nothing is stored). Provide exactly one of content or url per attachment.

{
  "to": "recipient@example.com",
  "subject": "Your invoice",
  "html": "<p>Invoice attached.</p>",
  "attachments": [
    { "filename": "invoice.pdf", "content": "JVBERi0xLjQK...", "content_type": "application/pdf" },
    { "filename": "report.csv", "url": "https://files.yourapp.com/report.csv" }
  ]
}

Host large files, attach small ones. Inline base64 is capped well below the request-body limit (a few MB) — for anything larger, host the file and pass a url. It's lighter on the wire and the recommended path for big files. See Deliverability for why.

Limits & errors. An oversized inline attachment, a disallowed file type, or an unreachable / disallowed URL is rejected with invalid_field (param: "attachments") and nothing is sent. A whole message past the ceiling of the provider that sends your domain is rejected with message_too_large (413); the error states the limit that applied. Treat 20 MB encoded as the figure you can rely on — some domains route through a provider that allows more, and none allow less. A large-but-sendable message succeeds and returns a soft warnings array nudging you to host the file instead.

Response

On success the API returns 200 with a JSON body of success, id, and message. The id is the email's raw UUID — pass it to GET /api/v1/emails/{id} to read its delivery status.

{
  "success": true,
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "status": "sent",
  "message": "Email sent successfully"
}

Every live email passes a quick security check before it goes out. A new template, or one that names a brand that isn't yours, is checked once and then remembered, so the rest of your stream is not slowed. When the check holds an email, the API returns 202 with status: "held": the email has not been sent yet, it keeps its id, and it goes out after a quick security review (GET /api/v1/emails/{id} shows held until then). Later emails of the same template wait with it. An email that is not cleared is not sent and does not count toward your allowance.

{
  "success": true,
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "status": "held",
  "message": "This email will go ahead after a quick security review."
}

On validation failure the API returns 400 with the standard error envelope (for example, a missing field):

{
  "error": {
    "code": "missing_field",
    "message": "Field 'to' is required.",
    "param": "to",
    "request_id": "req_8f3c9a1b2d4e5f6071829304"
  }
}

Every response — success or error — also includes an X-Request-Id header. See Error codes for the canonical envelope and the full list of possible errors.

Code examples

"color:#ff7b72">import { PristineSend } "color:#ff7b72">from "pristinesend"

"color:#ff7b72">const ps = "color:#ff7b72">new PristineSend(process.env.PRISTINESEND_API_KEY!)

"color:#ff7b72">const { id } = "color:#ff7b72">await ps.emails.send({
  to: "recipient@example.com",
  "color:#ff7b72">from: "orders@yourdomain.com", // omit to use your workspace default sender
  reply_to: "support@yourdomain.com", // or an array; falls back to the sender"color:#a5d6ff">'s default
  subject: "Your order has shipped!",
  html: "<h1>It's on the way</h1>",
  attachments: [
    // inline (small files) — base64 content
    { filename: "receipt.pdf", content: pdf.toString("base64"), content_type: "application/pdf" },
    // hosted (large files) — fetched at send time, never stored
    { filename: "manifest.csv", url: "https://files.yourapp.com/manifest.csv" },
  ],
})
// id is the email's UUID — read its status with ps.emails.get(id).
console.log("Sent:", id)

Testing? A ps_test_ key sends through the same path but captures instead of delivering — free, no reputation impact. See Test mode.

Rate limits

Every API key is limited to 1,000 requests per minute; over the limit you get a 429 rate_limited with a Retry-After header. To send in bulk, use Batch send rather than looping over /send. See Rate limits for the headers and handling.