ProtoPost exposes a simple HTTP API. Authentication is off by default for local development. When hosting publicly, enable bearer-token protection by setting the AUTH_TOKEN environment variable — see the Authentication section below and docs/AUTH.md for integration guidance.
http://localhost:8000
Replace localhost:8000 with your server's address if running on a different port or hosted remotely (e.g. https://protopost.onrender.com or https://protopost-production.up.railway.app).
Auth is off by default. No token is needed when running locally.
To protect the API (recommended when hosting on Render, Railway, or any public URL), set the AUTH_TOKEN environment variable on the server. Once set, every /api/* request must include:
Authorization: Bearer <your-token>
The dashboard automatically stores the token in localStorage and sends it with every request. Click the 🔓 lock icon in the top-right of the dashboard header to enter or clear the token.
401 response when token is wrong or missing:
{
"detail": "Unauthorized. Provide a valid Bearer token in the Authorization header."
}Note
GET /api/health is always unauthenticated — the dashboard uses it for the connection status dot.
All request bodies are JSON. Always include the Content-Type: application/json header.
Send an email through the gateway. This is the only endpoint your app needs to call.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
from |
string (email) | Yes | Sender address |
to |
array[string] | Yes | Recipient addresses (min 1, max 50) |
subject |
string | Yes | Email subject line |
body_text |
string | No* | Plain text body |
body_html |
string | No* | HTML body |
reply_to |
string (email) | No | Reply-to address |
*At least one of body_text or body_html must be provided.
Example Request
curl -X POST http://localhost:8000/api/send \
-H "Content-Type: application/json" \
-d '{
"from": "you@example.com",
"to": ["recipient@example.com"],
"subject": "Hello from the gateway",
"body_html": "<h1>It works!</h1>",
"body_text": "It works!"
}'Success Response (200)
{
"status": "success",
"message": "Email sent successfully via My Resend Provider",
"provider": {
"id": "a1b2c3d4-...",
"name": "My Resend Provider",
"type": "resend"
},
"log_id": "e5f6g7h8-...",
"processing_time_ms": 412.3,
"message_id": "msg_abc123"
}Sandbox Response (200)
{
"status": "sandbox",
"message": "Email intercepted by Sandbox Mode. Not sent.",
"log_id": "e5f6g7h8-...",
"processing_time_ms": 1.8
}Error Responses
422 Unprocessable Entity — Validation error (missing required field, invalid email, etc.):
{
"detail": [
{
"type": "missing",
"loc": ["body", "subject"],
"msg": "Field required"
}
]
}502 Bad Gateway — All providers failed:
{
"detail": "All providers failed. Errors: Resend: 403 Forbidden; Gmail: SMTPAuthenticationError"
}503 Service Unavailable — No providers configured or all disabled:
{
"detail": "No providers configured. Add a provider in the dashboard."
}Get a list of all sent (and sandbox) emails. Sorted by most recent first.
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
limit |
integer | 100 | Max results to return (max 500) |
offset |
integer | 0 | Pagination offset |
Example Request
curl "http://localhost:8000/api/logs?limit=20&offset=0"Success Response (200)
{
"logs": [
{
"id": "a1b2c3d4-...",
"timestamp": "2026-02-22T12:04:32.123Z",
"from_address": "you@example.com",
"to_addresses": ["recipient@example.com"],
"subject": "Hello!",
"provider_name": "My Resend Provider",
"provider_type": "resend",
"status": "success",
"processing_time_ms": 412.3
}
],
"total": 142,
"limit": 20,
"offset": 0
}Note: The
totalfield reflects the full database count across all pages, not the length of the current page's result set. For example, if there are 200 logs and you requestpage=1&limit=10, the response contains 10 logs buttotalis 200.
Get full details for a single log entry, including the email body.
Example Request
curl "http://localhost:8000/api/logs/a1b2c3d4-e5f6-..."Success Response (200)
{
"id": "a1b2c3d4-...",
"timestamp": "2026-02-22T12:04:32.123Z",
"from_address": "you@example.com",
"to_addresses": ["recipient@example.com"],
"subject": "Hello!",
"body_text": "It works!",
"body_html": "<h1>It works!</h1>",
"reply_to": null,
"provider_name": "My Resend Provider",
"provider_type": "resend",
"status": "success",
"error_message": null,
"message_id": "msg_abc123",
"processing_time_ms": 412.3
}Error Response (404)
{
"detail": "Log entry not found"
}Get aggregate statistics for all emails sent.
Example Request
curl "http://localhost:8000/api/stats"Success Response (200)
{
"total_sent": 142,
"total_failed": 2,
"total_sandbox": 58,
"avg_processing_time_ms": 340.1,
"by_provider": {
"My Resend Provider": {
"sent": 98,
"failed": 1
},
"My Gmail": {
"sent": 44,
"failed": 1
}
}
}Get the current gateway configuration.
Example Request
curl "http://localhost:8000/api/config"Success Response (200)
{
"providers": [
{
"id": "a1b2c3d4-...",
"name": "My Resend Provider",
"type": "resend",
"enabled": true,
"weight": 100
}
],
"routing": {
"mode": "smart",
"sandbox": false
},
"version": 1
}Note
Credential fields (API keys, passwords) are masked in this response. They are never returned in plaintext via the API.
Replace the entire configuration. Use this to restore a saved config or reset to defaults.
Example Request
curl -X PUT http://localhost:8000/api/config \
-H "Content-Type: application/json" \
-d '{
"providers": [],
"routing": {
"mode": "smart",
"sandbox": true
}
}'Success Response (200) — Returns the updated config (same format as GET /api/config).
Add a new provider.
Request Body (Resend)
{
"name": "My Resend Provider",
"type": "resend",
"enabled": true,
"weight": 100,
"api_key": "re_xxxxxxxxxxxxxxxxxxxxxxxx"
}Request Body (Gmail)
{
"name": "My Gmail",
"type": "gmail",
"enabled": true,
"weight": 50,
"gmail_address": "you@gmail.com",
"gmail_app_password": "xxxx xxxx xxxx xxxx"
}Request Body (Custom SMTP)
{
"name": "My SMTP",
"type": "custom_smtp",
"enabled": true,
"weight": 100,
"smtp_host": "smtp.sendgrid.net",
"smtp_port": 587,
"smtp_username": "apikey",
"smtp_password": "SG.xxxxxx",
"smtp_use_tls": true,
"smtp_use_ssl": false
}Success Response (201) — Returns the created provider object with its generated id.
Update an existing provider. The request body is the same format as POST /api/config/providers.
Example Request
curl -X PUT "http://localhost:8000/api/config/providers/a1b2c3d4-..." \
-H "Content-Type: application/json" \
-d '{
"name": "My Resend Provider",
"type": "resend",
"enabled": false,
"weight": 100,
"api_key": "re_new_key_here"
}'Success Response (200) — Returns the updated provider.
Error Response (404)
{
"detail": "Provider not found"
}Delete a provider permanently.
Example Request
curl -X DELETE "http://localhost:8000/api/config/providers/a1b2c3d4-..."Success Response (200)
{
"message": "Provider deleted successfully"
}Update routing mode and sandbox status.
Request Body
| Field | Type | Values | Description |
|---|---|---|---|
mode |
string | "smart", "manual" |
Routing algorithm |
sandbox |
boolean | true, false |
Enable/disable sandbox mode |
Example Request
curl -X POST http://localhost:8000/api/config/routing \
-H "Content-Type: application/json" \
-d '{"mode": "smart", "sandbox": false}'Success Response (200)
{
"mode": "smart",
"sandbox": false
}Health check. Use this to verify the server is running.
Example Request
curl "http://localhost:8000/api/health"Success Response (200)
{
"status": "healthy",
"version": "1.0.0"
}If you get a connection error, the server isn't running.
import httpx
async def send_email(to: str, subject: str, html: str):
async with httpx.AsyncClient() as client:
response = await client.post(
"http://localhost:8000/api/send",
json={
"from": "you@example.com",
"to": [to],
"subject": subject,
"body_html": html,
}
)
response.raise_for_status()
return response.json()import requests
def send_email(to: str, subject: str, html: str) -> dict:
response = requests.post(
"http://localhost:8000/api/send",
json={
"from": "you@example.com",
"to": [to],
"subject": subject,
"body_html": html,
}
)
response.raise_for_status()
return response.json()async function sendEmail(to, subject, html) {
const response = await fetch('http://localhost:8000/api/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
from: 'you@example.com',
to: [to],
subject,
body_html: html
})
});
if (!response.ok) {
throw new Error(`Email failed: ${await response.text()}`);
}
return response.json();
}const axios = require('axios');
const sendEmail = async (to, subject, html) => {
const { data } = await axios.post('http://localhost:8000/api/send', {
from: 'you@example.com',
to: [to],
subject,
body_html: html,
});
return data;
};curl -X POST http://localhost:8000/api/send \
-H "Content-Type: application/json" \
-d '{
"from": "you@example.com",
"to": ["recipient@example.com"],
"subject": "Hello from the gateway",
"body_html": "<h1>It works!</h1>",
"body_text": "It works!"
}'All error responses follow this shape:
{
"detail": {
"message": "Human-readable error description."
}
}For 500 errors specifically, the response is always:
{
"detail": {
"message": "Internal server error. Check server logs for details."
}
}Stack traces are never included in HTTP responses. All internal errors are logged server-side with full tracebacks for debugging.
- docs/AUTH.md — Full integration guide: storing the token in env vars, Python / JS / Node examples
- docs/PROVIDERS.md — Provider-specific fields for POST /api/config/providers
- docs/SANDBOX.md — What the sandbox response means
- docs/TROUBLESHOOTING.md — Fixing common 502 / 422 errors
- docs/HOSTING.md — Hosting options and where to set AUTH_TOKEN