Sendry provides a comprehensive HTTP API for sending emails, managing queues, templates, domains, and more.
All API endpoints (except /health) require authentication via API key:
curl -H "Authorization: Bearer YOUR_API_KEY" http://localhost:8080/api/v1/...API keys can be created and managed through the web interface at /settings/api-keys.
Features:
- Domain Restrictions: Limit API keys to send only from specific domains. If no domains are specified, the key can send from any configured domain.
- Rate Limits: Set per-minute and per-hour rate limits for each key.
- Expiration: Optionally set an expiration date for keys.
- Activity Tracking: View last used timestamp and total send count.
Error Responses:
| Code | Error | Description |
|---|---|---|
DOMAIN_NOT_ALLOWED |
403 | API key is not allowed to send from the specified domain |
UNAUTHORIZED |
401 | Invalid or missing API key |
RATE_LIMITED |
429 | Rate limit exceeded for this API key |
Default: http://localhost:8080
Check server status. No authentication required.
GET /health
Response:
{
"status": "ok",
"version": "0.2.0",
"uptime": "1h30m",
"queue": {
"pending": 5,
"sending": 1,
"delivered": 100,
"failed": 2,
"deferred": 3,
"total": 111
}
}Queue an email for delivery.
POST /api/v1/send
Request:
{
"from": "sender@example.com",
"to": ["recipient@example.com"],
"subject": "Hello",
"body": "Plain text content",
"html": "<p>HTML content</p>",
"headers": {
"X-Custom-Header": "value"
}
}| Field | Type | Required | Description |
|---|---|---|---|
from |
string | Yes | Sender email address |
to |
array | Yes | Recipient email addresses |
subject |
string | Yes* | Email subject |
body |
string | Yes* | Plain text body |
html |
string | No | HTML body |
headers |
object | No | Custom email headers |
*At least one of subject, body, or html is required.
Response (202 Accepted):
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"status": "pending"
}Queue multiple emails in a single request. Reduces HTTP overhead and BoltDB write-lock contention on large mailings. All valid messages are enqueued atomically in one transaction; invalid messages are reported per-item without aborting the rest of the batch.
POST /api/v1/send/batch
Request:
{
"messages": [
{ "from": "a@example.com", "to": ["x@example.com"], "subject": "s1", "body": "b1" },
{ "from": "b@example.com", "to": ["y@example.com"], "subject": "s2", "html": "<p>b2</p>" }
]
}Each element follows the same schema as POST /api/v1/send.
Limits:
- Maximum 1000 messages per request.
- Per-message size limit matches
smtp.max_message_bytes(default 10 MB).
Response:
{
"accepted": 2,
"rejected": 0,
"results": [
{ "index": 0, "id": "550e8400-...", "status": "pending" },
{ "index": 1, "id": "66aa77bb-...", "status": "pending" }
]
}On validation errors individual results carry an error field instead of
id/status:
{
"accepted": 1,
"rejected": 1,
"results": [
{ "index": 0, "id": "550e8400-...", "status": "pending" },
{ "index": 1, "error": "invalid to address: bad" }
]
}HTTP status is 202 Accepted whenever the request itself is well-formed,
even if some entries were rejected. 400 is returned for an empty
messages array, and 413 when the batch exceeds the 1000-message limit.
Get the delivery status of a message.
GET /api/v1/status/{id}
Response:
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"status": "delivered",
"from": "sender@example.com",
"to": ["recipient@example.com"],
"created_at": "2024-01-15T10:30:00Z",
"updated_at": "2024-01-15T10:30:05Z",
"retry_count": 0,
"last_error": ""
}Status values:
| Status | Description |
|---|---|
pending |
Waiting to be sent |
sending |
Currently being sent |
delivered |
Successfully delivered |
deferred |
Temporary failure, will retry |
failed |
Permanent failure |
Get queue statistics and list of messages.
GET /api/v1/queue
Response:
{
"stats": {
"pending": 5,
"sending": 1,
"delivered": 100,
"failed": 2,
"deferred": 3,
"total": 111
},
"messages": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"from": "sender@example.com",
"to": ["recipient@example.com"],
"status": "pending",
"created_at": "2024-01-15T10:30:00Z"
}
]
}Remove a message from the queue.
DELETE /api/v1/queue/{id}
Response: 204 No Content
Failed messages are moved to the DLQ for manual review.
GET /api/v1/dlq
Response:
{
"stats": {
"count": 5,
"oldest_at": "2024-01-10T08:00:00Z",
"newest_at": "2024-01-15T10:00:00Z"
},
"messages": [
{
"id": "...",
"from": "sender@example.com",
"to": ["recipient@example.com"],
"status": "failed",
"created_at": "2024-01-15T10:30:00Z"
}
]
}GET /api/v1/dlq/{id}
Response: Same as message status response.
Move a message back to the pending queue for retry.
POST /api/v1/dlq/{id}/retry
Response:
{
"status": "ok",
"message": "Message moved to pending queue"
}DELETE /api/v1/dlq/{id}
Response: 204 No Content
Email templates with variable substitution.
GET /api/v1/templates
Query Parameters:
| Parameter | Description |
|---|---|
search |
Search by name |
limit |
Max results (default: 100) |
offset |
Skip N results |
Response:
{
"templates": [
{
"id": "...",
"name": "welcome",
"description": "Welcome email",
"subject": "Welcome {{.Name}}!",
"html": "<h1>Hello {{.Name}}</h1>",
"text": "Hello {{.Name}}",
"variables": [
{"name": "Name", "required": true, "default": ""}
],
"version": 1,
"created_at": "2024-01-15T10:00:00Z",
"updated_at": "2024-01-15T10:00:00Z"
}
],
"total": 1
}POST /api/v1/templates
Request:
{
"name": "welcome",
"description": "Welcome email template",
"subject": "Welcome {{.Name}}!",
"html": "<h1>Hello {{.Name}}</h1>",
"text": "Hello {{.Name}}",
"variables": [
{"name": "Name", "required": true, "default": "User"}
]
}Response (201 Created): Template object.
GET /api/v1/templates/{id}
Note: {id} can be the template ID or name.
Response: Template object.
PUT /api/v1/templates/{id}
Request: Same as create (all fields optional).
Response: Updated template object.
DELETE /api/v1/templates/{id}
Response: 204 No Content
Render a template with sample data.
POST /api/v1/templates/{id}/preview
Request:
{
"data": {
"Name": "John"
}
}Response:
{
"subject": "Welcome John!",
"html": "<h1>Hello John</h1>",
"text": "Hello John"
}Send an email using a template.
POST /api/v1/send/template
Request:
{
"template_id": "...",
"template_name": "welcome",
"from": "noreply@example.com",
"to": ["user@example.com"],
"cc": [],
"bcc": [],
"data": {
"Name": "John"
},
"headers": {}
}Note: Provide either template_id or template_name.
Response (202 Accepted):
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"status": "pending"
}Sandbox mode captures emails locally for testing. Available when domains are configured with mode: sandbox or mode: redirect.
GET /api/v1/sandbox/messages
Query Parameters:
| Parameter | Description |
|---|---|
domain |
Filter by domain |
mode |
Filter by mode (sandbox/redirect/bcc) |
from |
Filter by sender |
limit |
Max results (default: 100) |
offset |
Skip N results |
Response:
{
"messages": [
{
"id": "...",
"from": "sender@example.com",
"to": ["test@sandbox.example.com"],
"original_to": ["real@example.com"],
"subject": "Test Email",
"domain": "sandbox.example.com",
"mode": "redirect",
"captured_at": "2024-01-15T10:30:00Z",
"client_ip": "192.168.1.100",
"simulated_error": ""
}
],
"total": 1
}GET /api/v1/sandbox/messages/{id}
Response:
{
"id": "...",
"from": "sender@example.com",
"to": ["test@sandbox.example.com"],
"subject": "Test Email",
"domain": "sandbox.example.com",
"mode": "sandbox",
"captured_at": "2024-01-15T10:30:00Z",
"headers": {
"From": "sender@example.com",
"To": "test@sandbox.example.com",
"Subject": "Test Email"
},
"body": "Plain text content",
"html": "<p>HTML content</p>",
"size": 1234
}Download the raw RFC 5322 email data.
GET /api/v1/sandbox/messages/{id}/raw
Response: message/rfc822 content with .eml filename.
Re-queue a sandbox message for actual delivery.
POST /api/v1/sandbox/messages/{id}/resend
Response:
{
"status": "queued",
"message_id": "...-resend-20240115103000"
}Delete multiple sandbox messages.
DELETE /api/v1/sandbox/messages
Query Parameters:
| Parameter | Description |
|---|---|
domain |
Only clear messages for this domain |
older_than |
Clear messages older than duration (e.g., 24h, 7d) |
Response:
{
"cleared": 50
}DELETE /api/v1/sandbox/messages/{id}
Response: 204 No Content
GET /api/v1/sandbox/stats
Response:
{
"total": 100,
"by_domain": {
"sandbox.example.com": 50,
"test.example.com": 50
},
"by_mode": {
"sandbox": 70,
"redirect": 30
},
"oldest_at": "2024-01-10T08:00:00Z",
"newest_at": "2024-01-15T10:30:00Z",
"total_size": 524288
}Manage domain configurations at runtime.
GET /api/v1/domains
Response:
{
"domains": [
{
"domain": "example.com",
"mode": "production",
"default_from": "noreply@example.com",
"dkim": {
"enabled": true,
"selector": "default"
},
"rate_limit": {
"messages_per_hour": 1000,
"messages_per_day": 10000
}
}
]
}POST /api/v1/domains
Request:
{
"domain": "newdomain.com",
"mode": "production",
"default_from": "noreply@newdomain.com",
"dkim": {
"enabled": true,
"selector": "default",
"key_file": "/path/to/key.pem"
},
"tls": {
"cert_file": "/path/to/cert.pem",
"key_file": "/path/to/key.pem"
},
"rate_limit": {
"messages_per_hour": 500,
"messages_per_day": 5000,
"recipients_per_message": 50
},
"redirect_to": [],
"bcc_to": []
}Response (201 Created): Domain object.
GET /api/v1/domains/{domain}
Response: Domain object.
PUT /api/v1/domains/{domain}
Request: Same as create (all fields optional).
Response: Updated domain object.
DELETE /api/v1/domains/{domain}
Note: Cannot delete the main SMTP domain.
Response: 204 No Content
POST /api/v1/dkim/generate
Request:
{
"domain": "example.com",
"selector": "default"
}Response (201 Created):
{
"domain": "example.com",
"selector": "default",
"dns_name": "default._domainkey.example.com",
"dns_record": "v=DKIM1; k=rsa; p=MIIBIjANBgkq...",
"key_file": "/var/lib/sendry/dkim/example.com/default.key"
}GET /api/v1/dkim/{domain}
Response:
{
"domain": "example.com",
"enabled": true,
"selector": "default",
"key_file": "/var/lib/sendry/dkim/example.com/default.key",
"dns_name": "default._domainkey.example.com",
"dns_record": "v=DKIM1; k=rsa; p=MIIBIjANBgkq...",
"selectors": ["default", "backup"]
}GET /api/v1/dkim/{domain}/verify?selector=default
Response:
{
"domain": "example.com",
"selector": "default",
"valid": true,
"error": "",
"dns_name": "default._domainkey.example.com"
}DELETE /api/v1/dkim/{domain}/{selector}
Response: 204 No Content
GET /api/v1/tls/certificates
Response:
{
"certificates": [
{
"domain": "mail.example.com",
"cert_file": "/path/to/cert.pem",
"key_file": "/path/to/key.pem",
"acme": false
}
],
"acme_enabled": true,
"acme_domains": ["mail.example.com"]
}POST /api/v1/tls/certificates
Request:
{
"domain": "mail.example.com",
"certificate": "-----BEGIN CERTIFICATE-----\n...",
"private_key": "-----BEGIN PRIVATE KEY-----\n..."
}Response (201 Created): Certificate info object.
POST /api/v1/tls/letsencrypt/{domain}
Note: Domain must be in the ACME allowed domains list.
Response (202 Accepted):
{
"status": "pending",
"message": "Certificate will be obtained automatically on first TLS connection",
"domain": "mail.example.com"
}GET /api/v1/ratelimits
Response:
{
"enabled": true,
"global": {
"messages_per_hour": 10000,
"messages_per_day": 100000
},
"default_domain": {
"messages_per_hour": 1000,
"messages_per_day": 10000
},
"default_sender": {
"messages_per_hour": 100,
"messages_per_day": 1000
},
"default_ip": {
"messages_per_hour": 500,
"messages_per_day": 5000
},
"default_api_key": {
"messages_per_hour": 1000,
"messages_per_day": 10000
},
"domains": {
"example.com": {
"messages_per_hour": 2000,
"messages_per_day": 20000,
"recipients_per_message": 100
}
}
}GET /api/v1/ratelimits/{level}/{key}
Levels: global, domain, sender, ip, api_key
Response:
{
"level": "domain",
"key": "example.com",
"hourly_count": 150,
"daily_count": 1500,
"hourly_limit": 1000,
"daily_limit": 10000
}PUT /api/v1/ratelimits/{domain}
Request:
{
"messages_per_hour": 2000,
"messages_per_day": 20000,
"recipients_per_message": 100
}Response: Updated rate limit object.
Check DNS records for domains and IP reputation.
GET /api/v1/dns/check/{domain}
Check MX, SPF, DKIM, DMARC, and MTA-STS records for a domain.
Query Parameters:
| Parameter | Default | Description |
|---|---|---|
mx |
false | Check only MX records |
spf |
false | Check only SPF record |
dkim |
false | Check only DKIM record |
dmarc |
false | Check only DMARC record |
mta_sts |
false | Check only MTA-STS record |
selector |
sendry | DKIM selector to check |
If no specific check is requested, all records are checked.
Response:
{
"domain": "example.com",
"results": [
{
"type": "MX Records",
"status": "ok",
"value": "mail.example.com (priority 10)",
"message": "1 MX record(s) found"
},
{
"type": "SPF Record",
"status": "ok",
"value": "v=spf1 include:_spf.example.com -all",
"message": "SPF configured with strict policy (-all)"
},
{
"type": "DKIM Record (sendry._domainkey)",
"status": "ok",
"value": "v=DKIM1; k=rsa; p=MIIBIjANBgkq...",
"message": "DKIM configured with RSA key"
},
{
"type": "DMARC Record",
"status": "ok",
"value": "v=DMARC1; p=reject; rua=mailto:dmarc@example.com",
"message": "DMARC configured with reject policy (strict)"
},
{
"type": "MTA-STS Record",
"status": "not_found",
"message": "No MTA-STS record found (optional)"
}
],
"summary": {
"ok": 4,
"warnings": 0,
"errors": 0,
"not_found": 1
}
}Status values: ok, warning, error, not_found
GET /api/v1/ip/check/{ip}
Check an IPv4 address against DNS-based blackhole lists (DNSBL).
Response:
{
"ip": "1.2.3.4",
"results": [
{
"dnsbl": {
"name": "Spamhaus ZEN",
"zone": "zen.spamhaus.org",
"description": "Combined Spamhaus blocklist (SBL, XBL, PBL)"
},
"listed": false,
"return_codes": null,
"error": ""
},
{
"dnsbl": {
"name": "Barracuda",
"zone": "b.barracudacentral.org",
"description": "Barracuda Reputation Block List"
},
"listed": true,
"return_codes": ["127.0.0.2"],
"error": ""
}
],
"summary": {
"clean": 17,
"listed": 1,
"errors": 0
}
}GET /api/v1/ip/dnsbls
List all DNS blacklist services that are checked.
Response:
{
"dnsbls": [
{
"name": "Spamhaus ZEN",
"zone": "zen.spamhaus.org",
"description": "Combined Spamhaus blocklist (SBL, XBL, PBL)"
},
{
"name": "Barracuda",
"zone": "b.barracudacentral.org",
"description": "Barracuda Reputation Block List"
}
],
"count": 15
}All endpoints return errors in the following format:
{
"error": "Error message description"
}Common HTTP Status Codes:
| Code | Description |
|---|---|
| 200 | Success |
| 201 | Created |
| 202 | Accepted (queued for processing) |
| 204 | No Content (successful deletion) |
| 400 | Bad Request (invalid input) |
| 401 | Unauthorized (missing/invalid API key) |
| 403 | Forbidden |
| 404 | Not Found |
| 409 | Conflict (e.g., duplicate name) |
| 429 | Too Many Requests (rate limited) |
| 500 | Internal Server Error |
| 503 | Service Unavailable |