REST API Reference
Base URL
Section titled “Base URL”https://api.relaypost.dev/v1Authentication
Section titled “Authentication”All requests require an API key in the Authorization header:
Authorization: Bearer YOUR_API_KEYAPI keys are created in the RelayPost dashboard under Settings → API Keys. See API Keys for details.
Response format
Section titled “Response format”All successful responses are wrapped in a data envelope:
{ "data": { ... }}Paginated responses include a pagination object:
{ "data": [...], "pagination": { "page": 1, "limit": 20, "total_count": 150, "total_pages": 8 }}Error responses use a consistent error envelope:
{ "error": { "code": "VALIDATION_ERROR", "message": "Request validation failed", "details": [ { "field": "to", "message": "At least one recipient is required" } ] }}See Error Codes for the full list.
Rate limiting
Section titled “Rate limiting”Every response includes rate limit headers. See Rate Limits for details.
| Header | Description |
|---|---|
X-RateLimit-Limit | Maximum requests allowed in the current window |
X-RateLimit-Remaining | Requests remaining in the current window |
Retry-After | Seconds to wait before retrying (only on 429 responses) |
Emails
Section titled “Emails”Send an email
Section titled “Send an email”POST /api/v1/emails/send
curl -X POST https://api.relaypost.dev/v1/emails/send \ -H "Content-Type: application/json" \ -H "Authorization: Bearer YOUR_API_KEY" \ -d '{ "from": { "email": "hello@yourdomain.com", "name": "Your App" }, "to": [{ "email": "user@example.com" }], "subject": "Your receipt", "html": "<h1>Thanks for your purchase!</h1>" }'const response = await fetch("https://api.relaypost.dev/v1/emails/send", { method: "POST", headers: { "Content-Type": "application/json", "Authorization": "Bearer YOUR_API_KEY", }, body: JSON.stringify({ from: { email: "hello@yourdomain.com", name: "Your App" }, to: [{ email: "user@example.com" }], subject: "Your receipt", html: "<h1>Thanks for your purchase!</h1>", }),});
const result = await response.json();console.log(result.data.message_id);import requests
response = requests.post( "https://api.relaypost.dev/v1/emails/send", headers={ "Content-Type": "application/json", "Authorization": "Bearer YOUR_API_KEY", }, json={ "from": {"email": "hello@yourdomain.com", "name": "Your App"}, "to": [{"email": "user@example.com"}], "subject": "Your receipt", "html": "<h1>Thanks for your purchase!</h1>", },)
result = response.json()print(result["data"]["message_id"])require "net/http"require "json"require "uri"
uri = URI("https://api.relaypost.dev/v1/emails/send")http = Net::HTTP.new(uri.host, uri.port)http.use_ssl = true
request = Net::HTTP::Post.new(uri)request["Content-Type"] = "application/json"request["Authorization"] = "Bearer YOUR_API_KEY"request.body = {from: { email: "hello@yourdomain.com", name: "Your App" },to: [{ email: "user@example.com" }],subject: "Your receipt",html: "<h1>Thanks for your purchase!</h1>"}.to_json
response = http.request(request)data = JSON.parse(response.body)["data"]puts data["message_id"]use GuzzleHttp\Client;
$client = new Client(["base_uri" => "https://api.relaypost.dev"]);
$response = $client->post("/v1/emails/send", [ "headers" => ["Authorization" => "Bearer YOUR_API_KEY"], "json" => [ "from" => ["email" => "hello@yourdomain.com", "name" => "Your App"], "to" => [["email" => "user@example.com"]], "subject" => "Your receipt", "html" => "<h1>Thanks for your purchase!</h1>", ],]);
$data = json_decode($response->getBody(), true)["data"];echo $data["message_id"];Request body
Section titled “Request body”| Field | Type | Required | Description |
|---|---|---|---|
from | object | Yes | Sender — { "email": "...", "name": "..." } |
to | array | Yes | Recipients — [{ "email": "...", "name": "..." }] |
cc | array | No | CC recipients |
bcc | array | No | BCC recipients |
subject | string | Yes | Email subject line |
html | string | No | HTML body |
text | string | No | Plain text body |
template_id | string | No | Use a saved template instead of inline content |
template_data | object | No | Key-value pairs for template variables |
headers | object | No | Custom email headers |
scheduled_at | string | No | ISO 8601 datetime for scheduled delivery |
You must provide either html, text, or template_id.
Response (201)
Section titled “Response (201)”{ "data": { "message_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "status": "queued", "queued_at": "2025-01-15T10:30:00.000Z" }}Errors
Section titled “Errors”| Status | Code | Description |
|---|---|---|
| 400 | VALIDATION_ERROR | Missing or invalid fields |
| 400 | DOMAIN_NOT_VERIFIED | From address uses an unverified domain |
| 400 | RECIPIENTS_SUPPRESSED | One or more recipients are on the suppression list |
| 400 | TEMPLATE_INACTIVE | Template is not active |
| 400 | TEMPLATE_MISSING_VARIABLES | Required template variables missing |
| 400 | TEMPLATE_DATA_INVALID | Template data type mismatch |
| 404 | TEMPLATE_NOT_FOUND | Template ID not found or belongs to another org |
| 409 | TEMPLATE_NAME_CONFLICT | Template name already exists (on create) |
| 429 | LIMIT_EXCEEDED | Organization email sending limit exceeded |
List emails
Section titled “List emails”GET /api/v1/emails
Query parameters
Section titled “Query parameters”| Parameter | Type | Default | Description |
|---|---|---|---|
status | string | — | Filter by status: queued, delivered, bounced, failed, opened |
from_date | string | — | ISO 8601 start date |
to_date | string | — | ISO 8601 end date |
page | integer | 1 | Page number |
limit | integer | 20 | Results per page (max 100) |
Response (200)
Section titled “Response (200)”{ "data": [ { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "message_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "from_address": "hello@yourdomain.com", "to_addresses": ["user@example.com"], "subject": "Your receipt", "status": "delivered", "priority": "normal", "created_at": "2025-01-15T10:30:00.000Z", "updated_at": "2025-01-15T10:30:05.000Z" } ], "pagination": { "page": 1, "limit": 20, "total_count": 42, "total_pages": 3 }}Get email by ID
Section titled “Get email by ID”GET /api/v1/emails/:id
Retrieve a specific email by its ID or message ID, including delivery events.
Response (200)
Section titled “Response (200)”{ "data": { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "message_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "from_address": "hello@yourdomain.com", "to_addresses": ["user@example.com"], "subject": "Your receipt", "status": "delivered", "priority": "normal", "created_at": "2025-01-15T10:30:00.000Z", "updated_at": "2025-01-15T10:30:05.000Z", "scheduled_at": null, "events": [ { "id": "evt_001", "type": "delivered", "recipient": "user@example.com", "smtp_code": 250, "smtp_message": "OK", "created_at": "2025-01-15T10:30:05.000Z" } ] }}Errors
Section titled “Errors”| Status | Code | Description |
|---|---|---|
| 404 | NOT_FOUND | Email not found or belongs to another organization |
Email Validation
Section titled “Email Validation”Validate a single email
Section titled “Validate a single email”POST /api/v1/emails/validate
Run the full validation pipeline on a single email address. Returns a risk score, verdict, and detailed check results.
curl -X POST https://api.relaypost.dev/v1/emails/validate \ -H "Content-Type: application/json" \ -H "Authorization: Bearer YOUR_API_KEY" \ -d '{ "email": "user@example.com" }'const response = await fetch("https://api.relaypost.dev/v1/emails/validate", { method: "POST", headers: { "Content-Type": "application/json", "Authorization": "Bearer YOUR_API_KEY", }, body: JSON.stringify({ email: "user@example.com" }),});
const result = await response.json();console.log(result.data.verdict); // "deliverable", "risky", or "undeliverable"import requests
response = requests.post( "https://api.relaypost.dev/v1/emails/validate", headers={ "Content-Type": "application/json", "Authorization": "Bearer YOUR_API_KEY", }, json={"email": "user@example.com"},)
result = response.json()print(result["data"]["verdict"])require "net/http"require "json"require "uri"
uri = URI("https://api.relaypost.dev/v1/emails/validate")http = Net::HTTP.new(uri.host, uri.port)http.use_ssl = true
request = Net::HTTP::Post.new(uri)request["Content-Type"] = "application/json"request["Authorization"] = "Bearer YOUR_API_KEY"request.body = { email: "user@example.com" }.to_json
response = http.request(request)data = JSON.parse(response.body)["data"]puts data["verdict"]use GuzzleHttp\Client;
$client = new Client(["base_uri" => "https://api.relaypost.dev"]);
$response = $client->post("/v1/emails/validate", [ "headers" => ["Authorization" => "Bearer YOUR_API_KEY"], "json" => ["email" => "user@example.com"],]);
$data = json_decode($response->getBody(), true)["data"];echo $data["verdict"];Request body
Section titled “Request body”| Field | Type | Required | Description |
|---|---|---|---|
email | string | Yes | The email address to validate |
Response (200)
Section titled “Response (200)”{ "data": { "email": "user@example.com", "verdict": "deliverable", "risk_score": 0.0, "checks": { "syntax": { "status": "pass" }, "mx": { "status": "pass", "records": [{ "priority": 10, "exchange": "mx.example.com" }] }, "dns": { "status": "pass", "addresses": ["93.184.216.34"] }, "disposable": { "status": "pass" }, "role": { "status": "pass" }, "typo": { "status": "pass", "suggestions": [] }, "free_provider": { "status": "pass", "provider_type": "unknown" }, "random": { "status": "pass" } }, "suggestions": [], "timestamp": "2025-01-15T10:30:00.000Z" }}Verdict values
Section titled “Verdict values”| Verdict | Description |
|---|---|
deliverable | Email is likely valid and safe to send to |
risky | Email may have issues — disposable, role-based, or typo |
undeliverable | Email is invalid — bad syntax, no MX records, or nonexistent |
Check statuses
Section titled “Check statuses”| Status | Description |
|---|---|
pass | Check passed — no issues found |
fail | Check failed — issue detected |
warn | Advisory warning — not necessarily a problem |
unknown | Check could not be completed (e.g. DNS timeout) |
Errors
Section titled “Errors”| Status | Code | Description |
|---|---|---|
| 400 | VALIDATION_ERROR | Missing or invalid email field |
Validate a batch of emails
Section titled “Validate a batch of emails”POST /api/v1/emails/validate/batch
Validate up to 100 email addresses in a single request.
curl -X POST https://api.relaypost.dev/v1/emails/validate/batch \ -H "Content-Type: application/json" \ -H "Authorization: Bearer YOUR_API_KEY" \ -d '{ "emails": [ "user@example.com", "invalid@", "test@tempmail.com" ] }'const response = await fetch( "https://api.relaypost.dev/v1/emails/validate/batch", { method: "POST", headers: { "Content-Type": "application/json", "Authorization": "Bearer YOUR_API_KEY", }, body: JSON.stringify({ emails: ["user@example.com", "invalid@", "test@tempmail.com"], }), });
const result = await response.json();result.data.forEach((r) => console.log(r.email, r.verdict));import requests
response = requests.post( "https://api.relaypost.dev/v1/emails/validate/batch", headers={ "Content-Type": "application/json", "Authorization": "Bearer YOUR_API_KEY", }, json={ "emails": ["user@example.com", "invalid@", "test@tempmail.com"] },)
result = response.json()for r in result["data"]: print(r["email"], r["verdict"])require "net/http"require "json"require "uri"
uri = URI("https://api.relaypost.dev/v1/emails/validate/batch")http = Net::HTTP.new(uri.host, uri.port)http.use_ssl = true
request = Net::HTTP::Post.new(uri)request["Content-Type"] = "application/json"request["Authorization"] = "Bearer YOUR_API_KEY"request.body = {emails: ["user@example.com", "invalid@", "test@tempmail.com"]}.to_json
response = http.request(request)JSON.parse(response.body)["data"].each do |r|puts "#{r["email"]} — #{r["verdict"]}"enduse GuzzleHttp\Client;
$client = new Client(["base_uri" => "https://api.relaypost.dev"]);
$response = $client->post("/v1/emails/validate/batch", [ "headers" => ["Authorization" => "Bearer YOUR_API_KEY"], "json" => ["emails" => ["user@example.com", "invalid@", "test@tempmail.com"]],]);
$results = json_decode($response->getBody(), true)["data"];foreach ($results as $r) { echo $r["email"] . " — " . $r["verdict"] . "\n";}Request body
Section titled “Request body”| Field | Type | Required | Description |
|---|---|---|---|
emails | string[] | Yes | Array of email addresses to validate (max 100) |
Response (200)
Section titled “Response (200)”{ "data": [ { "email": "user@example.com", "verdict": "deliverable", "risk_score": 0.0, "checks": { ... }, "suggestions": [], "timestamp": "2025-01-15T10:30:00.000Z" }, { "email": "invalid@", "verdict": "undeliverable", "risk_score": 1.0, "checks": { ... }, "suggestions": [], "timestamp": "2025-01-15T10:30:00.000Z" } ]}Each item in the response array has the same shape as the single validation response.
Errors
Section titled “Errors”| Status | Code | Description |
|---|---|---|
| 400 | VALIDATION_ERROR | Missing emails field, not an array, or exceeds 100 |
Settings
Section titled “Settings”Get auto-validation threshold
Section titled “Get auto-validation threshold”GET /api/v1/settings/auto-validation
Returns the current auto-validation threshold for your organization. When enabled, emails are automatically validated before sending and rejected if they exceed the risk threshold.
curl "https://api.relaypost.dev/v1/settings/auto-validation" \ -H "Authorization: Bearer YOUR_API_KEY"const response = await fetch( "https://api.relaypost.dev/v1/settings/auto-validation", { headers: { "Authorization": "Bearer YOUR_API_KEY" }, });
const result = await response.json();console.log(result.data.auto_validation_threshold);import requests
response = requests.get( "https://api.relaypost.dev/v1/settings/auto-validation", headers={"Authorization": "Bearer YOUR_API_KEY"},)
result = response.json()print(result["data"]["auto_validation_threshold"])require "net/http"require "json"require "uri"
uri = URI("https://api.relaypost.dev/v1/settings/auto-validation")http = Net::HTTP.new(uri.host, uri.port)http.use_ssl = true
request = Net::HTTP::Get.new(uri)request["Authorization"] = "Bearer YOUR_API_KEY"
response = http.request(request)data = JSON.parse(response.body)["data"]puts data["auto_validation_threshold"]use GuzzleHttp\Client;
$client = new Client(["base_uri" => "https://api.relaypost.dev"]);
$response = $client->get("/v1/settings/auto-validation", [ "headers" => ["Authorization" => "Bearer YOUR_API_KEY"],]);
$data = json_decode($response->getBody(), true)["data"];echo $data["auto_validation_threshold"];Response (200)
Section titled “Response (200)”{ "data": { "auto_validation_threshold": "off" }}Update auto-validation threshold
Section titled “Update auto-validation threshold”PUT /api/v1/settings/auto-validation
curl -X PUT https://api.relaypost.dev/v1/settings/auto-validation \ -H "Content-Type: application/json" \ -H "Authorization: Bearer YOUR_API_KEY" \ -d '{ "threshold": "high" }'const response = await fetch( "https://api.relaypost.dev/v1/settings/auto-validation", { method: "PUT", headers: { "Content-Type": "application/json", "Authorization": "Bearer YOUR_API_KEY", }, body: JSON.stringify({ threshold: "high" }), });
const result = await response.json();console.log(result.data.auto_validation_threshold);import requests
response = requests.put( "https://api.relaypost.dev/v1/settings/auto-validation", headers={ "Content-Type": "application/json", "Authorization": "Bearer YOUR_API_KEY", }, json={"threshold": "high"},)
result = response.json()print(result["data"]["auto_validation_threshold"])require "net/http"require "json"require "uri"
uri = URI("https://api.relaypost.dev/v1/settings/auto-validation")http = Net::HTTP.new(uri.host, uri.port)http.use_ssl = true
request = Net::HTTP::Put.new(uri)request["Content-Type"] = "application/json"request["Authorization"] = "Bearer YOUR_API_KEY"request.body = { threshold: "high" }.to_json
response = http.request(request)data = JSON.parse(response.body)["data"]puts data["auto_validation_threshold"]use GuzzleHttp\Client;
$client = new Client(["base_uri" => "https://api.relaypost.dev"]);
$response = $client->put("/v1/settings/auto-validation", [ "headers" => ["Authorization" => "Bearer YOUR_API_KEY"], "json" => ["threshold" => "high"],]);
$data = json_decode($response->getBody(), true)["data"];echo $data["auto_validation_threshold"];Request body
Section titled “Request body”| Field | Type | Required | Description |
|---|---|---|---|
threshold | string | Yes | One of: high, medium, off (default) |
Threshold values
Section titled “Threshold values”| Value | Behavior |
|---|---|
high | Reject only undeliverable emails (risk score > 0.7) |
medium | Reject undeliverable and risky emails (risk score > 0.3) |
off | No automatic validation — all emails are sent regardless of risk score |
Response (200)
Section titled “Response (200)”{ "data": { "auto_validation_threshold": "high" }}Errors
Section titled “Errors”| Status | Code | Description |
|---|---|---|
| 400 | VALIDATION_ERROR | Invalid threshold value |
Domains
Section titled “Domains”Add a domain
Section titled “Add a domain”POST /api/v1/domains
Request body
Section titled “Request body”| Field | Type | Required | Description |
|---|---|---|---|
domain | string | Yes | The domain name (e.g. example.com) |
Response (201)
Section titled “Response (201)”{ "data": { "id": "dom_abc123", "domain": "example.com", "is_verified": false, "spf_verified": false, "dkim_verified": false, "dkim_selector": "relaypost", "dkim_public_key": "MIGfMA0GCSqGSIb3DQEBAQUAA4...", "dns_records": [ { "type": "TXT", "name": "relaypost._domainkey.example.com", "value": "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4..." }, { "type": "TXT", "name": "example.com", "value": "v=spf1 include:relaypost.dev ~all" } ], "created_at": "2025-01-15T10:30:00.000Z" }}List domains
Section titled “List domains”GET /api/v1/domains
Response (200)
Section titled “Response (200)”{ "data": [ { "id": "dom_abc123", "domain": "example.com", "is_verified": true, "spf_verified": true, "dkim_verified": true, "dkim_selector": "relaypost", "dkim_public_key": "MIGfMA0GCSqGSIb3DQEBAQUAA4...", "verified_at": "2025-01-15T11:00:00.000Z", "created_at": "2025-01-15T10:30:00.000Z" } ]}Verify domain DNS
Section titled “Verify domain DNS”GET /api/v1/domains/:id/verify
Performs DNS verification and returns the updated domain status.
Response (200)
Section titled “Response (200)”{ "data": { "id": "dom_abc123", "domain": "example.com", "is_verified": true, "spf_verified": true, "dkim_verified": true, "dkim_selector": "relaypost", "dkim_public_key": "MIGfMA0GCSqGSIb3DQEBAQUAA4...", "verified_at": "2025-01-15T11:00:00.000Z", "created_at": "2025-01-15T10:30:00.000Z" }}Errors
Section titled “Errors”| Status | Code | Description |
|---|---|---|
| 404 | NOT_FOUND | Domain not found or belongs to another organization |
Suppressions
Section titled “Suppressions”List suppressions
Section titled “List suppressions”GET /api/v1/suppressions
Query parameters
Section titled “Query parameters”| Parameter | Type | Default | Description |
|---|---|---|---|
page | integer | 1 | Page number |
limit | integer | 20 | Results per page (max 100) |
Response (200)
Section titled “Response (200)”{ "data": [ { "id": "sup_abc123", "email": "bounced@example.com", "reason": "hard_bounce", "source": "automatic", "created_at": "2025-01-15T10:30:00.000Z" } ], "pagination": { "page": 1, "limit": 20, "total_count": 15, "total_pages": 1 }}Add a suppression
Section titled “Add a suppression”POST /api/v1/suppressions
Request body
Section titled “Request body”| Field | Type | Required | Description |
|---|---|---|---|
email | string | Yes | Email address to suppress |
reason | string | Yes | One of: hard_bounce, soft_bounce, complaint, unsubscribe, manual |
Response (201)
Section titled “Response (201)”{ "data": { "id": "sup_def456", "email": "bounced@example.com", "reason": "hard_bounce", "source": "manual", "created_at": "2025-01-15T10:30:00.000Z" }}Errors
Section titled “Errors”| Status | Code | Description |
|---|---|---|
| 400 | VALIDATION_ERROR | Invalid email or reason |
Remove a suppression
Section titled “Remove a suppression”DELETE /api/v1/suppressions/:id
Response (200)
Section titled “Response (200)”{ "data": { "id": "sup_abc123", "deleted": true }}Errors
Section titled “Errors”| Status | Code | Description |
|---|---|---|
| 404 | NOT_FOUND | Suppression not found or belongs to another organization |
Templates
Section titled “Templates”List templates
Section titled “List templates”GET /api/v1/templates
Query parameters
Section titled “Query parameters”| Parameter | Type | Default | Description |
|---|---|---|---|
page | integer | 1 | Page number |
limit | integer | 20 | Results per page (max 100) |
Response (200)
Section titled “Response (200)”{ "data": [ { "id": "tmpl_abc123", "name": "Order Confirmation", "subject": "Your order #{{orderNumber}}", "html_body": "...", "text_body": "...", "variables": ["orderNumber", "customerName", "total"], "variable_schema": null, "is_active": true, "created_at": "2025-01-15T10:30:00.000Z", "updated_at": "2025-01-15T10:30:00.000Z" } ], "pagination": { "page": 1, "limit": 20, "total_count": 5, "total_pages": 1 }}Create a template
Section titled “Create a template”POST /api/v1/templates
Request body
Section titled “Request body”| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Template name |
subject | string | Yes | Subject line (supports {{variables}}) |
html | string | No | HTML body (supports {{variables}}) |
text | string | No | Plain text body (supports {{variables}}) |
variable_schema | array | No | Typed variable definitions — see Variable Schema below |
Response (201)
Section titled “Response (201)”{ "data": { "id": "tmpl_abc123", "name": "Order Confirmation", "subject": "Your order #{{orderNumber}}", "html_body": "<h1>Thanks, {{customerName}}!</h1>", "text_body": "Thanks, {{customerName}}!", "variables": ["orderNumber", "customerName"], "variable_schema": null, "is_active": true, "created_at": "2025-01-15T10:30:00.000Z", "updated_at": "2025-01-15T10:30:00.000Z" }}Update a template
Section titled “Update a template”PUT /api/v1/templates/:id
Only include the fields you want to change.
Request body
Section titled “Request body”| Field | Type | Required | Description |
|---|---|---|---|
name | string | No | Updated template name |
subject | string | No | Updated subject line |
html | string | No | Updated HTML body |
text | string | No | Updated plain text body |
variable_schema | array | No | Updated variable definitions |
is_active | boolean | No | Activate or deactivate |
At least one field must be provided.
Response (200)
Section titled “Response (200)”{ "data": { "id": "tmpl_abc123", "name": "Order Confirmation", "subject": "Your order #{{orderNumber}}", "html_body": "<h1>Updated layout</h1>", "text_body": "Thanks, {{customerName}}!", "variables": ["orderNumber", "customerName"], "variable_schema": null, "is_active": true, "created_at": "2025-01-15T10:30:00.000Z", "updated_at": "2025-01-15T11:00:00.000Z" }}Errors
Section titled “Errors”| Status | Code | Description |
|---|---|---|
| 400 | VALIDATION_ERROR | No update fields provided or invalid values |
| 404 | NOT_FOUND | Template not found or belongs to another organization |
| 409 | TEMPLATE_NAME_CONFLICT | Template name already exists |
Delete a template
Section titled “Delete a template”DELETE /api/v1/templates/:id
Soft-deletes the template by setting is_active to false. The template remains in the database so historical email records referencing it stay valid. Use PUT /api/v1/templates/:id with is_active: true to reactivate.
Response (200)
Section titled “Response (200)”{ "data": { "id": "tmpl_abc123", "name": "Order Confirmation", "subject": "Your order #{{orderNumber}}", "html_body": "<h1>Thanks, {{customerName}}!</h1>", "text_body": "Thanks, {{customerName}}!", "variables": ["orderNumber", "customerName"], "variable_schema": null, "is_active": false, "created_at": "2025-01-15T10:30:00.000Z", "updated_at": "2025-01-15T12:00:00.000Z" }}Errors
Section titled “Errors”| Status | Code | Description |
|---|---|---|
| 404 | NOT_FOUND | Template not found or belongs to another organization |
Variable Schema
Section titled “Variable Schema”Templates support an optional variable_schema field that defines typed variable declarations. When provided, template_data is validated against the schema at send time.
Each entry in the array has the following shape:
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Variable name as referenced in template content |
type | string | Yes | string, number, boolean, array, or object |
required | boolean | Yes | Whether the variable must be provided at send time |
default_value | any | No | Default value when the variable is not provided (must match type) |
Example schema:
{ "variable_schema": [ { "name": "customerName", "type": "string", "required": true }, { "name": "orderNumber", "type": "string", "required": true }, { "name": "total", "type": "string", "required": true }, { "name": "items", "type": "array", "required": false, "default_value": [] } ]}Variables are also auto-detected from {{variable}} placeholders in the subject, HTML, and text bodies. The variables field in the response lists all detected variables. If a variable appears in the content but is not declared in variable_schema, a warning is returned in the response.
Preview a template
Section titled “Preview a template”POST /api/v1/templates/:id/preview
Preview a rendered template with sample data. Missing variables render as empty strings with warnings. Inactive templates can be previewed.
curl -X POST https://api.relaypost.dev/v1/templates/tmpl_abc123/preview \ -H "Content-Type: application/json" \ -H "Authorization: Bearer YOUR_API_KEY" \ -d '{ "data": { "customerName": "Jane", "orderNumber": "1234", "total": "$49.99" } }'const templateId = "tmpl_abc123";const response = await fetch( `https://api.relaypost.dev/v1/templates/${templateId}/preview`, { method: "POST", headers: { "Content-Type": "application/json", "Authorization": "Bearer YOUR_API_KEY", }, body: JSON.stringify({ data: { customerName: "Jane", orderNumber: "1234", total: "$49.99", }, }), });
const result = await response.json();console.log(result.data.subject);console.log(result.data.html);if (result.data.warnings.length > 0) {console.log("Warnings:", result.data.warnings);}import requests
template_id = "tmpl_abc123"response = requests.post( f"https://api.relaypost.dev/v1/templates/{template_id}/preview", headers={ "Content-Type": "application/json", "Authorization": "Bearer YOUR_API_KEY", }, json={ "data": { "customerName": "Jane", "orderNumber": "1234", "total": "$49.99", } },)
result = response.json()print(result["data"]["subject"])print(result["data"]["html"])for w in result["data"]["warnings"]: print(f"Warning: {w}")require "net/http"require "json"require "uri"
template_id = "tmpl_abc123"uri = URI("https://api.relaypost.dev/v1/templates/#{template_id}/preview")http = Net::HTTP.new(uri.host, uri.port)http.use_ssl = true
request = Net::HTTP::Post.new(uri)request["Content-Type"] = "application/json"request["Authorization"] = "Bearer YOUR_API_KEY"request.body = {data: {customerName: "Jane",orderNumber: "1234",total: "$49.99"}}.to_json
response = http.request(request)data = JSON.parse(response.body)["data"]puts data["subject"]puts data["html"]data["warnings"].each { |w| puts "Warning: #{w}" }use GuzzleHttp\Client;
$client = new Client(["base_uri" => "https://api.relaypost.dev"]);$templateId = "tmpl_abc123";
$response = $client->post("/v1/templates/{$templateId}/preview", [ "headers" => ["Authorization" => "Bearer YOUR_API_KEY"], "json" => [ "data" => [ "customerName" => "Jane", "orderNumber" => "1234", "total" => "$49.99", ], ],]);
$data = json_decode($response->getBody(), true)["data"];echo $data["subject"] . "\n";echo $data["html"] . "\n";foreach ($data["warnings"] as $w) { echo "Warning: {$w}\n";}Request body
Section titled “Request body”| Field | Type | Required | Description |
|---|---|---|---|
data | object | Yes | Sample data for template variable substitution |
Response (200)
Section titled “Response (200)”{ "data": { "subject": "Your order #1234", "html": "<h1>Thanks, Jane!</h1><p>Your order #1234 totaling $49.99 has been confirmed.</p>", "text": "Thanks, Jane! Your order #1234 totaling $49.99 has been confirmed.", "variables": ["orderNumber", "customerName", "total"], "warnings": [] }}When variables are missing, they render as empty strings and appear in warnings:
{ "data": { "subject": "Your order #", "html": "<h1>Thanks, Jane!</h1><p>Your order # totaling has been confirmed.</p>", "text": "Thanks, Jane! Your order # totaling has been confirmed.", "variables": ["orderNumber", "customerName", "total"], "warnings": ["Missing variable: orderNumber", "Missing variable: total"] }}Errors
Section titled “Errors”| Status | Code | Description |
|---|---|---|
| 400 | VALIDATION_ERROR | Missing or invalid data field |
| 404 | TEMPLATE_NOT_FOUND | Template not found or belongs to another organization |
Batch send with templates
Section titled “Batch send with templates”POST /api/v1/emails/batch
Send the same template to multiple recipients with per-recipient variable data. Maximum 1000 recipients per batch.
curl -X POST https://api.relaypost.dev/v1/emails/batch \ -H "Content-Type: application/json" \ -H "Authorization: Bearer YOUR_API_KEY" \ -d '{ "template_id": "tmpl_abc123", "from": { "email": "orders@yourapp.com", "name": "Your Store" }, "subject": "Your order is confirmed", "recipients": [ { "to": { "email": "alice@example.com", "name": "Alice" }, "data": { "customerName": "Alice", "orderNumber": "ORD-001", "total": "$29.99" } }, { "to": { "email": "bob@example.com", "name": "Bob" }, "data": { "customerName": "Bob", "orderNumber": "ORD-002", "total": "$59.99" } } ] }'const response = await fetch("https://api.relaypost.dev/v1/emails/batch", { method: "POST", headers: { "Content-Type": "application/json", "Authorization": "Bearer YOUR_API_KEY", }, body: JSON.stringify({ template_id: "tmpl_abc123", from: { email: "orders@yourapp.com", name: "Your Store" }, subject: "Your order is confirmed", recipients: [ { to: { email: "alice@example.com", name: "Alice" }, data: { customerName: "Alice", orderNumber: "ORD-001", total: "$29.99" }, }, { to: { email: "bob@example.com", name: "Bob" }, data: { customerName: "Bob", orderNumber: "ORD-002", total: "$59.99" }, }, ], }),});
const result = await response.json();console.log(`Batch ${result.data.batch_id}: ${result.data.accepted}/${result.data.total} accepted`);result.data.rejected.forEach((r) =>console.log(`Rejected #${r.index}: ${r.error}`));import requests
response = requests.post( "https://api.relaypost.dev/v1/emails/batch", headers={ "Content-Type": "application/json", "Authorization": "Bearer YOUR_API_KEY", }, json={ "template_id": "tmpl_abc123", "from": {"email": "orders@yourapp.com", "name": "Your Store"}, "subject": "Your order is confirmed", "recipients": [ { "to": {"email": "alice@example.com", "name": "Alice"}, "data": {"customerName": "Alice", "orderNumber": "ORD-001", "total": "$29.99"}, }, { "to": {"email": "bob@example.com", "name": "Bob"}, "data": {"customerName": "Bob", "orderNumber": "ORD-002", "total": "$59.99"}, }, ], },)
data = response.json()["data"]print(f"Batch {data['batch_id']}: {data['accepted']}/{data['total']} accepted")for r in data["rejected"]: print(f"Rejected #{r['index']}: {r['error']}")require "net/http"require "json"require "uri"
uri = URI("https://api.relaypost.dev/v1/emails/batch")http = Net::HTTP.new(uri.host, uri.port)http.use_ssl = true
request = Net::HTTP::Post.new(uri)request["Content-Type"] = "application/json"request["Authorization"] = "Bearer YOUR_API_KEY"request.body = {template_id: "tmpl_abc123",from: { email: "orders@yourapp.com", name: "Your Store" },subject: "Your order is confirmed",recipients: [{to: { email: "alice@example.com", name: "Alice" },data: { customerName: "Alice", orderNumber: "ORD-001", total: "$29.99" }},{to: { email: "bob@example.com", name: "Bob" },data: { customerName: "Bob", orderNumber: "ORD-002", total: "$59.99" }}]}.to_json
response = http.request(request)data = JSON.parse(response.body)["data"]puts "Batch #{data["batch_id"]}: #{data["accepted"]}/#{data["total"]} accepted"data["rejected"].each { |r| puts "Rejected ##{r["index"]}: #{r["error"]}" }use GuzzleHttp\Client;
$client = new Client(["base_uri" => "https://api.relaypost.dev"]);
$response = $client->post("/v1/emails/batch", [ "headers" => ["Authorization" => "Bearer YOUR_API_KEY"], "json" => [ "template_id" => "tmpl_abc123", "from" => ["email" => "orders@yourapp.com", "name" => "Your Store"], "subject" => "Your order is confirmed", "recipients" => [ [ "to" => ["email" => "alice@example.com", "name" => "Alice"], "data" => ["customerName" => "Alice", "orderNumber" => "ORD-001", "total" => "$29.99"], ], [ "to" => ["email" => "bob@example.com", "name" => "Bob"], "data" => ["customerName" => "Bob", "orderNumber" => "ORD-002", "total" => "$59.99"], ], ], ],]);
$data = json_decode($response->getBody(), true)["data"];echo "Batch {$data['batch_id']}: {$data['accepted']}/{$data['total']} accepted\n";foreach ($data["rejected"] as $r) { echo "Rejected #{$r['index']}: {$r['error']}\n";}Request body
Section titled “Request body”| Field | Type | Required | Description |
|---|---|---|---|
template_id | string | Yes | Template ID to use for all recipients |
from | object | Yes | Sender — { "email": "...", "name": "..." } |
subject | string | No | Optional subject override (replaces template subject) |
recipients | array | Yes | Array of recipients (max 1000), each with to and data |
Each item in recipients:
| Field | Type | Required | Description |
|---|---|---|---|
to | object | Yes | Recipient — { "email": "...", "name": "..." } |
data | object | Yes | Per-recipient template variable data |
Response (200)
Section titled “Response (200)”{ "data": { "batch_id": "550e8400-e29b-41d4-a716-446655440000", "total": 2, "accepted": 2, "rejected": [] }}Partial rejection example:
{ "data": { "batch_id": "550e8400-e29b-41d4-a716-446655440000", "total": 3, "accepted": 2, "rejected": [ { "index": 1, "to": "bob@example.com", "error": "Missing required variable: orderNumber" } ] }}Errors
Section titled “Errors”| Status | Code | Description |
|---|---|---|
| 400 | BATCH_TOO_LARGE | Exceeds 1000 recipient limit |
| 400 | VALIDATION_ERROR | Missing required fields or invalid request body |
| 400 | TEMPLATE_INACTIVE | Template is not active |
| 402 | BILLING_LIMIT_EXCEEDED | Batch would exceed your plan’s email limit |
| 404 | TEMPLATE_NOT_FOUND | Template not found or belongs to another organization |