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:. |
402 | email_quota_exceeded: the month’s production allowance is used up and the balance cannot pay for the next pack. See Monthly allowance. |
403 | Key valid but the service is disabled, or sending is paused by the platform. |
429 | Daily / per-minute rate limit reached. |
503 | billing_unavailable: the allowance could not be checked. Nothing was sent; retry after the Retry-After header. |
5xx | Transient failure inside the platform. Safe to retry with exponential backoff. |
The body is always JSON:
{ "error": { "type": "email_quota_exceeded", "message": "Human-readable explanation" } }type is stable and safe to branch on; message is for people and may change.
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, payload or billing errors, and a retry fails the same way. A 402 clears only after a top-up.
Monthly allowance
Section titled “Monthly allowance”Production emails, the ones sent through a verified domain, count against a monthly allowance per workspace. Sandbox emails are free and never count.
- Each calendar month (UTC) includes a free quota.
- Past it, the email that crosses a 1000 boundary buys the next pack of 1000 from your balance at once. The charge appears in Billing → Transactions as
Email: 1000-email pack #N. - Packs are paid from topped-up funds only. Trial credit does not apply.
- An unused part of a pack expires at the end of the month.
When the balance cannot pay for the next pack, the API refuses the email with 402:
{ "error": { "type": "email_quota_exceeded", "message": "Monthly free quota of 3000 emails is used up and the balance is too low for the next 1000-email pack (€1.00). Top up to continue sending.", "quota": { "period": "2026-10", "sent": 3000, "allowance": 3000, "free_monthly": 3000, "packs_bought": 0, "pack_size": 1000, "pack_price_cents": 100 } }}Sending resumes as soon as the balance covers the next pack. The current month’s usage is shown on the email service page in the dashboard.