Skip to content

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.

POST https://api.runsite.app/emails

Required headers:

Api-Key: rs_eml_…
Content-Type: application/json
{
"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"
}
}
FieldTypeRequiredNotes
fromstringyesSender. 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.
tostring[]yesAt least one recipient.
ccstring[]noDefaults to [].
bccstring[]noDefaults to [].
reply_tostring[]noSets the Reply-To: header.
subjectstringyesPlain text.
messagestringone of message / textHTML body. Plain-text version is auto-derived from this if you omit text.
textstringone of message / textPlain-text body.
headersobjectnoExtra X-… headers as a flat JSON object.

Limit: up to 50 recipients total across to + cc + bcc per request. For more, send multiple requests.

{
"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"
}
FieldNotes
idRunsite’s UUID for the message. Use it to look up status later.
statussent (handed to the upstream relay) or failed. Final delivery state arrives via webhooks.
modeproduction, sandbox or sandbox_fallback. Useful for log filtering.
fromThe effective From: actually used (may be rewritten in sandbox).
messageHuman-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_…
StatusCause
400Validation error (no recipients, both message and text missing, malformed address, > 50 recipients, sandbox recipient not verified).
401Missing or invalid Api-Key:.
403Key valid but the service is disabled.
429Daily / per-minute rate limit reached.
5xxTransient failure inside the platform. Safe to retry with exponential backoff.

The body is always JSON: { "error": "human-readable explanation" }.

Terminal window
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>"
}'
import os
import 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"])
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();
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);
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
$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;

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.

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.

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.