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 it must be on a verified domain, otherwise the message goes out through 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:.
402email_quota_exceeded: the month’s production allowance is used up and the balance cannot pay for the next pack. See Monthly allowance.
403Key valid but the service is disabled, or sending is paused by the platform.
429Daily / per-minute rate limit reached.
503billing_unavailable: the allowance could not be checked. Nothing was sent; retry after the Retry-After header.
5xxTransient 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.

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, 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.

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 or billing errors, and a retry fails the same way. A 402 clears only after a top-up.

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.

IP geolocation by DB-IP