Sending emails
Sending an email is a single POST to the public endpoint. The same endpoint serves both sandbox and production traffic — what changes is the API key and (in production) the verified From: address.
Endpoint
Section titled “Endpoint”POST https://api.runsite.app/emailsRequired headers:
Api-Key: rs_eml_…Content-Type: application/jsonRequest body
Section titled “Request body”{ "from": "Acme Support <support@yourcompany.com>", "to": ["user@example.com"], "cc": [], "bcc": [], "reply_to": [], "subject": "Welcome to Acme", "message": "<p>Hi there — welcome aboard!</p>", "text": "Hi there — welcome aboard!", "headers": { "X-App-Id": "checkout-flow" }}| Field | Type | Required | Notes |
|---|---|---|---|
from | string | yes | Sender. Bare (a@b.com) or display-name form ("Name <a@b.com>"). In production, must be on a verified domain — otherwise the message is sent via the sandbox sender. |
to | string[] | yes | At least one recipient. |
cc | string[] | no | Defaults to []. |
bcc | string[] | no | Defaults to []. |
reply_to | string[] | no | Sets the Reply-To: header. |
subject | string | yes | Plain text. |
message | string | one of message / text | HTML body. Plain-text version is auto-derived from this if you omit text. |
text | string | one of message / text | Plain-text body. |
headers | object | no | Extra X-… headers as a flat JSON object. |
Limit: up to 50 recipients total across to + cc + bcc per request. For more, send multiple requests.
Response
Section titled “Response”{ "id": "9f1c47e0-4e8e-4d6d-9a92-1e2e3f4d5b6c", "status": "sent", "mode": "production", "from": "Acme Support <support@yourcompany.com>", "to": ["user@example.com"], "message": "Email sent to user@example.com via domain yourcompany.com"}| Field | Notes |
|---|---|
id | Runsite’s UUID for the message. Use it to look up status later. |
status | sent (handed to the upstream relay) or failed. Final delivery state arrives via webhooks. |
mode | production, sandbox or sandbox_fallback. Useful for log filtering. |
from | The effective From: actually used (may be rewritten in sandbox). |
message | Human-readable summary of what happened. |
You can later fetch the same record by ID:
GET https://api.runsite.app/emails/{id}Api-Key: rs_eml_…Error codes
Section titled “Error codes”| Status | Cause |
|---|---|
400 | Validation error (no recipients, both message and text missing, malformed address, > 50 recipients, sandbox recipient not verified). |
401 | Missing or invalid Api-Key:. |
403 | Key valid but the service is disabled. |
429 | Daily / per-minute rate limit reached. |
5xx | Transient failure inside the platform. Safe to retry with exponential backoff. |
The body is always JSON: { "error": "human-readable explanation" }.
Code examples
Section titled “Code examples”curl https://api.runsite.app/emails \ -H "Api-Key: rs_eml_a8K2pX7dL3qVnWj4mC9bRzT1yU6sH0eF" \ -H "Content-Type: application/json" \ -d '{ "from": "Acme <hello@yourcompany.com>", "to": ["user@example.com"], "subject": "Welcome", "message": "<p>Welcome to Acme.</p>" }'Python — requests
Section titled “Python — requests”import osimport requests
response = requests.post( "https://api.runsite.app/emails", headers={ "Api-Key": os.environ["EMAIL_API_KEY"], "Content-Type": "application/json", }, json={ "from": "Acme <hello@yourcompany.com>", "to": ["user@example.com"], "subject": "Welcome", "message": "<p>Welcome to Acme.</p>", }, timeout=10,)response.raise_for_status()print(response.json()["id"])JavaScript — fetch
Section titled “JavaScript — fetch”const response = await fetch("https://api.runsite.app/emails", { method: "POST", headers: { "Api-Key": process.env.EMAIL_API_KEY, "Content-Type": "application/json", }, body: JSON.stringify({ from: "Acme <hello@yourcompany.com>", to: ["user@example.com"], subject: "Welcome", message: "<p>Welcome to Acme.</p>", }),});
if (!response.ok) { throw new Error(`Email failed: ${response.status} ${await response.text()}`);}
const { id } = await response.json();Node.js — axios
Section titled “Node.js — axios”import axios from "axios";
const { data } = await axios.post( "https://api.runsite.app/emails", { from: "Acme <hello@yourcompany.com>", to: ["user@example.com"], subject: "Welcome", message: "<p>Welcome to Acme.</p>", }, { headers: { "Api-Key": process.env.EMAIL_API_KEY, "Content-Type": "application/json", }, timeout: 10_000, },);
console.log(data.id);Go — net/http
Section titled “Go — net/http”package main
import ( "bytes" "encoding/json" "fmt" "io" "net/http" "os")
func main() { payload, _ := json.Marshal(map[string]any{ "from": "Acme <hello@yourcompany.com>", "to": []string{"user@example.com"}, "subject": "Welcome", "message": "<p>Welcome to Acme.</p>", })
req, _ := http.NewRequest("POST", "https://api.runsite.app/emails", bytes.NewBuffer(payload)) req.Header.Set("Api-Key", os.Getenv("EMAIL_API_KEY")) req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body) fmt.Println(string(body))}PHP — cURL
Section titled “PHP — cURL”<?php
$payload = json_encode([ "from" => "Acme <hello@yourcompany.com>", "to" => ["user@example.com"], "subject" => "Welcome", "message" => "<p>Welcome to Acme.</p>",]);
$ch = curl_init("https://api.runsite.app/emails");curl_setopt($ch, CURLOPT_POST, true);curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);curl_setopt($ch, CURLOPT_HTTPHEADER, [ "Api-Key: " . getenv("EMAIL_API_KEY"), "Content-Type: application/json",]);
$response = curl_exec($ch);curl_close($ch);
echo $response;Plain text + HTML
Section titled “Plain text + HTML”If you provide only message (HTML), Runsite generates a plain-text version automatically — good enough for most clients. To control the plain-text version yourself, send both:
{ "subject": "Your code is 482931", "message": "<p>Your verification code is <strong>482931</strong>.</p>", "text": "Your verification code is 482931."}Always provide a real plain-text body for security-critical messages (codes, password resets, login notifications) — many clients hide HTML by default and rely on the text part for previews and alt rendering.
Custom headers
Section titled “Custom headers”The headers field accepts a flat object of extra X-… headers — useful for tagging, threading or downstream filtering:
{ "headers": { "X-App-Id": "checkout-flow", "X-Customer-Id": "cust_5419", "X-Tenant": "acme" }}Reserved headers (From, To, Subject, DKIM-Signature, etc.) cannot be overridden.
Rate limits
Section titled “Rate limits”Each service has a per-day cap that grows with your sending reputation. Sandbox services have a much lower cap (a few dozen messages a day) — see Sandbox mode.
When you exceed the limit the API returns 429 Too Many Requests. Back off and retry; do not queue thousands of requests in a tight loop.
Recommended retry strategy
Retry only on 5xx and 429. Use exponential backoff (1s → 2s → 4s → 8s) with at most 4 attempts and add jitter. Don’t retry 4xx other than 429 — those are configuration / payload errors and a retry will fail the same way.