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 it must be on a verified domain, otherwise the message goes out through 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, which is 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 fall back to 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 or payload errors, and a retry fails the same way.