
CourierX Docs
One API. Multiple email providers. Automatic failover, BYOK routing, and built-in suppressions.
Overview
CourierX is an open-source multi-provider email routing platform. It provides a single REST API that routes emails through SendGrid, AWS SES, Mailgun, Postmark, Resend, or SMTP — with automatic failover, per-tenant isolation, and BYOK (Bring Your Own Keys) credential management.
Architecture
CourierX uses a two-service design: a Rails Control Plane for API management, authentication, business logic, and multi-tenancy; and a Go Core Engine for high-performance email execution and provider routing.
Client App
│
▼
Rails Control Plane (Port 4000)
├─ REST API (auth, validation, business logic)
├─ Multi-tenancy, BYOK credential storage
└─ Writes Email → OutboxEvent → enqueues job
│
│ POST /v1/send (X-Internal-Secret header)
▼
Go Core Engine (Port 8080)
├─ Provider routing + automatic failover
├─ Handlebars template rendering
└─ Direct DB writes for events/status
│
▼
Email Providers (SendGrid / SES / Mailgun / Postmark / Resend / SMTP)Key Features
Multi-Provider Failover
Automatic switching between providers on transient errors or rate limits. Permanent errors stop immediately.
BYOK Model
Each tenant connects their own provider API keys. Credentials are AES-256 encrypted at rest.
Multi-Tenant Isolation
Every resource is scoped by tenant_id. Cross-tenant data access is impossible by design.
Suppression Lists
Built-in bounce, complaint, unsubscribe, and manual suppression handling per tenant.
Template Rendering
Handlebars template variables in subject, HTML, and text bodies — rendered at send time in Go.
Webhook Delivery
Register webhook endpoints to receive delivery, bounce, open, click, and complaint events with HMAC signing.
Supported Providers
SendGrid
apiKeyAWS SES
accessKeyId, secretAccessKey, regionMailgun
apiKey, domain, regionPostmark
serverTokenResend
apiKeySMTP
host, port, user, passGetting Started
Get CourierX running locally, create your first tenant, and send an email in under 5 minutes.
Docker Quick Start
The fastest way to run the full stack (Rails + Go + PostgreSQL + Redis + Sidekiq):
git clone https://github.com/courierx/courierx
cd courierx/infra
docker compose up -d
# Rails Control Plane → http://localhost:4000
# Go Core Engine → http://localhost:8080Native Development Setup
For faster iteration, run databases in Docker and services natively:
# Start only PostgreSQL + Redis
./infra/scripts/setup-dev-light.sh
# Terminal 1 — Rails Control Plane
cd backend/control-plane
bundle install
bundle exec rails db:create db:migrate db:seed
bundle exec rails server -p 4000
# Terminal 2 — Go Core Engine
cd backend/core-go
go mod download && go run main.go
# Terminal 3 — Sidekiq (background jobs)
cd backend/control-plane
bundle exec sidekiqEnvironment Variables
Configure these environment variables for both services:
Rails Control Plane
DATABASE_URL=postgresql://...
REDIS_URL=redis://localhost:6379
JWT_SECRET=your-jwt-secret
ENCRYPTION_KEY=your-32-char-key
GO_CORE_URL=http://localhost:8080
GO_CORE_SECRET=shared-secret-here
SECRET_KEY_BASE=128-char-secretGo Core Engine
DATABASE_URL=postgresql://...
INTERNAL_SECRET=shared-secret-here
PORT=8080
LOG_LEVEL=info
MAX_WORKERS=100
ENABLE_METRICS=true
IDEMPOTENCY_TTL=86400Send Your First Email
Register a tenant, get a token, and send:
# 1. Register a tenant
curl -X POST http://localhost:4000/api/v1/auth/register \
-H "Content-Type: application/json" \
-d '{
"name": "My App",
"email": "admin@myapp.com",
"password": "secure-password-123",
"password_confirmation": "secure-password-123"
}'
# → Returns { "tenant": {...}, "token": "eyJ..." }
# 2. Create an API key
curl -X POST http://localhost:4000/api/v1/api_keys \
-H "Authorization: Bearer eyJ..." \
-H "Content-Type: application/json" \
-d '{"name": "Production Key"}'
# → Returns { "raw_key": "cxk_live_abc123..." }
# 3. Send an email
curl -X POST http://localhost:4000/api/v1/emails \
-H "Authorization: Bearer cxk_live_abc123..." \
-H "Content-Type: application/json" \
-d '{
"from_email": "hello@myapp.com",
"to_email": "user@example.com",
"subject": "Welcome to My App",
"html_body": "<h1>Welcome!</h1><p>Thanks for signing up.</p>",
"text_body": "Welcome! Thanks for signing up.",
"tags": ["welcome", "onboarding"]
}'
# → Returns 202 with { "id": "uuid", "status": "queued" }Authentication
CourierX supports two authentication methods for API access: JWT tokens and API keys.
API Keys (Recommended)
For server-to-server integration, use API keys. Keys are prefixed with cxk_ and stored as SHA-256 hashes. The raw key is returned only once at creation — store it securely.
# Pass your API key as a Bearer token
curl -X POST http://localhost:4000/api/v1/emails \
-H "Authorization: Bearer cxk_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{ ... }'JWT Tokens
For dashboard users. Tokens are issued on login/register and expire after 24 hours. The token contains the tenant_id and is signed with JWT_SECRET.
# Login to get a JWT token
curl -X POST http://localhost:4000/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"email": "admin@myapp.com", "password": "secure-password-123"}'
# → { "tenant": {...}, "token": "eyJ..." }
# Use the token
curl http://localhost:4000/api/v1/emails \
-H "Authorization: Bearer eyJ..."
Response Headers
Authenticated responses include rate-limit information:
| Header | Description |
|---|---|
| X-RateLimit-Limit | Max requests per minute |
| X-RateLimit-Remaining | Requests remaining in window |
| X-RateLimit-Reset | Unix timestamp when limit resets |
| X-Total-Count | Total records (on list endpoints) |
| X-Page / X-Per-Page | Pagination metadata |
Guides
Step-by-step guides for configuring providers, domains, routing, and more.
Connect a Provider (BYOK)
Each tenant brings their own provider API keys. Credentials are encrypted at rest with AES-256 and never returned in API responses.
# Connect your SendGrid account
curl -X POST http://localhost:4000/api/v1/provider_connections \
-H "Authorization: Bearer cxk_live_..." \
-H "Content-Type: application/json" \
-d '{
"provider": "sendgrid",
"mode": "live",
"display_name": "SendGrid - Primary",
"priority": 1,
"api_key": "SG.your-sendgrid-api-key"
}'
# Connect a fallback provider
curl -X POST http://localhost:4000/api/v1/provider_connections \
-H "Authorization: Bearer cxk_live_..." \
-H "Content-Type: application/json" \
-d '{
"provider": "mailgun",
"mode": "live",
"display_name": "Mailgun - Fallback",
"priority": 2,
"api_key": "key-your-mailgun-key",
"smtp_host": "mg.yourdomain.com",
"region": "us"
}'Supported providers: sendgrid, mailgun, aws_ses, postmark, resend, smtp
Verify Provider Credentials
After connecting a provider, verify the credentials are valid:
# Verify a provider connection
curl -X POST http://localhost:4000/api/v1/provider_connections/:id/verify \
-H "Authorization: Bearer cxk_live_..."
# → { "verification": { "status": "verified", "message": "..." } }The Go engine validates credentials per provider: SendGrid checks the user profile API, Mailgun lists domains, SES calls GetSendQuota, SMTP performs a TLS handshake, etc.
Domain Verification
Register your sending domains and verify ownership via DNS:
# Add a sending domain
curl -X POST http://localhost:4000/api/v1/domains \
-H "Authorization: Bearer cxk_live_..." \
-H "Content-Type: application/json" \
-d '{"domain": "mail.yourapp.com"}'
# → { "verification_token": "dns_abc123...", "status": "pending" }
# Add a TXT record to your DNS:
# _courierx.mail.yourapp.com → dns_abc123...
# Then verify
curl -X POST http://localhost:4000/api/v1/domains/:id/verify \
-H "Authorization: Bearer cxk_live_..."
# → { "status": "verified", "verified_at": "..." }Routing Rules
Define how emails are routed through your connected providers. Strategies include priority, weighted, round_robin, and failover_only.
curl -X POST http://localhost:4000/api/v1/routing_rules \
-H "Authorization: Bearer cxk_live_..." \
-H "Content-Type: application/json" \
-d '{
"name": "Default routing",
"strategy": "failover",
"is_default": true,
"is_active": true
}'Webhook Endpoints
Register a URL to receive email delivery events. All webhook payloads are signed with HMAC (SHA-256) using the auto-generated secret.
curl -X POST http://localhost:4000/api/v1/webhook_endpoints \
-H "Authorization: Bearer cxk_live_..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://yourapp.com/webhooks/email",
"description": "Delivery events",
"events": ["delivered", "bounced", "complained", "opened", "clicked"]
}'Event types: delivered, bounced, complained, opened, clicked, unsubscribed, failed
Suppression Lists
Emails to suppressed addresses are blocked automatically before reaching any provider. Suppressions are created from bounces, complaints, or manually.
# Manually suppress an address
curl -X POST http://localhost:4000/api/v1/suppressions \
-H "Authorization: Bearer cxk_live_..." \
-H "Content-Type: application/json" \
-d '{
"email": "bad-address@example.com",
"reason": "manual",
"note": "Customer requested removal"
}'
# List suppressions
curl http://localhost:4000/api/v1/suppressions?reason=bounce \
-H "Authorization: Bearer cxk_live_..."API Reference
Complete reference for the CourierX REST API. All endpoints require authentication unless marked public. Base URL: http://localhost:4000/api/v1
Auth
Create a new tenant account. Public endpoint.
// Request
{
"name": "My Company",
"email": "admin@mycompany.com",
"password": "secure-password",
"password_confirmation": "secure-password"
}
// Response (201)
{
"tenant": {
"id": "uuid",
"name": "My Company",
"slug": "my-company",
"email": "admin@mycompany.com",
"status": "active",
"plan_id": "free"
},
"token": "eyJhbGciOiJIUzI1NiJ9..."
}Authenticate and receive a JWT token. Public endpoint.
// Request
{ "email": "admin@mycompany.com", "password": "secure-password" }
// Response (200)
{ "tenant": { ... }, "token": "eyJ..." }
// Error (401)
{ "error": "Invalid email or password" }Get the current tenant profile.
Update tenant name or settings.
Delete tenant account. Returns 204.
Emails
Send an email. Returns 202 Accepted. The email is queued and processed asynchronously via the outbox pattern.
// Request
{
"from_email": "noreply@yourapp.com",
"from_name": "Your App",
"to_email": "user@example.com",
"to_name": "Alice",
"reply_to": "support@yourapp.com",
"subject": "Welcome to {{appName}}",
"html_body": "<h1>Welcome {{name}}!</h1>",
"text_body": "Welcome {{name}}!",
"tags": ["welcome", "onboarding"],
"metadata": {
"idempotency_key": "unique-req-id",
"user_id": "usr_123"
}
}
// Response (202 Accepted)
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"from_email": "noreply@yourapp.com",
"to_email": "user@example.com",
"subject": "Welcome to {{appName}}",
"status": "queued",
"tags": ["welcome", "onboarding"],
"queued_at": "2026-03-22T10:30:00Z",
"created_at": "2026-03-22T10:30:00Z"
}Idempotency: if the same idempotency_key is sent within 24h, the existing email is returned. Suppressed recipients return 422.
List emails with optional filters. Paginated (default 25, max 100 per page).
| Parameter | Description |
|---|---|
| status | Filter: queued, sent, delivered, bounced, failed, suppressed |
| recipient | Partial match on to_email |
| from | Start date (ISO 8601) |
| to | End date (ISO 8601) |
| page | Page number (default 1) |
| per_page | Results per page (default 25, max 100) |
Get a single email with its full event timeline (delivered, bounced, opened, clicked, etc.).
API Keys
Create a new API key. The raw_key is returned only once — store it securely.
// Request
{
"name": "Production Key",
"scopes": ["emails:write", "analytics:read"]
}
// Response (201)
{
"id": "uuid",
"name": "Production Key",
"key_prefix": "cxk_live_abcd...",
"status": "active",
"scopes": ["emails:write", "analytics:read"],
"raw_key": "cxk_live_abcd1234..." // ← shown ONCE
}List all API keys (raw key is never returned again).
Revoke an API key. Sets status to "revoked".
Permanently delete an API key. Returns 204.
Provider Connections
List all provider connections, sorted by priority.
Connect a new provider with credentials. Credentials are encrypted before storage. Triggers automatic verification.
Update connection settings or rotate credentials.
Re-verify provider credentials.
Remove a provider connection. Returns 204.
Domains
List all sending domains with verification status.
Register a new sending domain. Returns a verification token for DNS.
Trigger DNS verification for a domain.
Remove a domain. Returns 204.
Routing Rules
List all routing rules.
Create a routing rule. Strategies: priority, weighted, round_robin, failover_only.
Update a routing rule.
Delete a routing rule. Returns 204.
Suppressions
List suppressed emails. Filter by reason: bounce, complaint, unsubscribe, manual.
Add an email to the suppression list.
Remove a suppression. Returns 204.
Webhook Endpoints
List all registered webhook endpoints.
Register a webhook URL. A secret is auto-generated for HMAC payload signing.
Update a webhook endpoint.
Remove a webhook endpoint. Returns 204.
Dashboard & Analytics
Get email delivery metrics. Query param: period (7d, 30d, 90d).
// Response
{
"period": { "from": "...", "to": "..." },
"totals": {
"sent": 5000, "delivered": 4950,
"bounced": 30, "complained": 5,
"opened": 2475, "clicked": 375
},
"rates": { "delivery_rate": 99.0, "open_rate": 50.0 },
"daily": [
{ "date": "2026-03-22", "sent": 750, "delivered": 745, ... }
],
"providers": [
{ "provider": "sendgrid", "status": "active", "success_rate": 99.5 }
]
}Get daily usage statistics. Query params: from, to (ISO dates).
Get compliance trust score and DNS verification status for DKIM, SPF, DMARC.
Health Checks
Rails health check. Public. Returns 200 if healthy, 503 if degraded.
{
"status": "ok",
"timestamp": "2026-03-22T12:34:56Z",
"services": {
"database": { "status": "ok", "adapter": "PostgreSQL" },
"redis": { "status": "ok" },
"sidekiq": { "status": "ok", "workers": 3, "enqueued": 150 }
}
}Go engine readiness probe (checks database).
Go engine liveness probe (always 200 if process is up).
Go Core Engine (Internal)
These endpoints are called by the Rails Control Plane, not by clients directly. They require the X-Internal-Secret header.
Send a single email through the provider failover chain.
// Request (from Rails OutboxProcessorJob)
{
"from": "noreply@yourapp.com",
"to": "user@example.com",
"subject": "Hello {{name}}",
"html": "<h1>Hello {{name}}</h1>",
"text": "Hello {{name}}",
"variables": { "name": "Alice" },
"tags": ["welcome"],
"tenantId": "tenant-uuid",
"idempotencyKey": "req-12345",
"providers": [
{
"priority": 1,
"role": "primary",
"provider": {
"type": "sendgrid",
"config": { "apiKey": "SG.decrypted-key" }
}
}
]
}
// Response
{
"success": true,
"messageId": "sg-abc123def456",
"provider": "sendgrid",
"durationMs": 234
}Send to up to 1,000 recipients in parallel. Same provider routing, processed by a bounded worker pool (default 10 workers).
// Request
{
"from": "noreply@yourapp.com",
"subject": "Order {{orderId}} shipped",
"html": "<p>Order {{orderId}} has shipped.</p>",
"recipients": [
{ "email": "user1@example.com", "variables": { "orderId": "ORD-001" } },
{ "email": "user2@example.com", "variables": { "orderId": "ORD-002" } }
],
"providers": [ ... ]
}
// Response
{
"success": true,
"total": 2,
"successCount": 2,
"failureCount": 0,
"results": [ ... ]
}Verify provider credentials without sending an email.
// Request
{ "provider": "sendgrid", "config": { "apiKey": "SG...." } }
// Response
{ "verified": true, "provider": "sendgrid", "error": null }Error Responses
All errors follow a consistent format:
// Validation error (422)
{ "errors": ["Email can't be blank", "Subject is required"] }
// Authentication error (401)
{ "error": "Unauthorized" }
// Not found (404)
{ "error": "Not found" }
// Suppressed recipient (422)
{ "error": "Recipient is suppressed" }
// Rate limited (429)
{ "error": "Rate limit exceeded" }SDKs & Examples
Official SDKs and code examples for integrating CourierX into your application.
Node.js / TypeScript SDK
@courierx/nodenpm install @courierx/nodeimport { CourierX } from "@courierx/node"
const courierx = new CourierX({
apiKey: "cxk_live_your_api_key",
// baseUrl: "https://api.courierx.dev" (default)
})
// Send an email
const result = await courierx.emails.send({
from: "hello@yourapp.com",
to: "user@example.com",
subject: "Welcome!",
html: "<h1>Welcome to our platform</h1>",
text: "Welcome to our platform",
tags: ["welcome"],
metadata: { userId: "usr_123" },
})
console.log(result.email.id) // "550e8400-..."
console.log(result.email.status) // "queued"
// List emails
const emails = await courierx.emails.list({
status: "delivered",
page: 1,
perPage: 50,
})
// Get email with events
const email = await courierx.emails.get("email-uuid")
console.log(email.events) // [{ event_type: "delivered", ... }]
// Manage domains
const domains = await courierx.domains.list()
await courierx.domains.verify("domain-uuid")
// Manage API keys
const key = await courierx.apiKeys.create({ name: "Staging" })
console.log(key.key) // "cxk_live_..." (shown once)REST API with fetch
const API_KEY = "cxk_live_your_api_key"
const BASE_URL = "https://api.courierx.dev"
const response = await fetch(`${BASE_URL}/api/v1/emails`, {
method: "POST",
headers: {
"Authorization": `Bearer ${API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
from_email: "hello@yourapp.com",
to_email: "user@example.com",
subject: "Hello from CourierX",
html_body: "<h1>It works!</h1>",
text_body: "It works!",
tags: ["test"],
}),
})
const { id, status } = await response.json()
console.log(id, status) // "uuid", "queued"Python
import requests
API_KEY = "cxk_live_your_api_key"
BASE_URL = "https://api.courierx.dev"
response = requests.post(
f"{BASE_URL}/api/v1/emails",
headers={
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
},
json={
"from_email": "hello@yourapp.com",
"to_email": "user@example.com",
"subject": "Hello from CourierX",
"html_body": "<h1>It works!</h1>",
"text_body": "It works!",
"tags": ["test"],
},
)
data = response.json()
print(data["id"], data["status"]) # "uuid", "queued"cURL
curl -X POST https://api.courierx.dev/api/v1/emails \
-H "Authorization: Bearer cxk_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"from_email": "hello@yourapp.com",
"to_email": "user@example.com",
"subject": "Hello from CourierX",
"html_body": "<h1>It works!</h1>",
"text_body": "It works!",
"tags": ["test"]
}'Deployment
CourierX ships with deployment configs for major cloud platforms and self-hosting.
Docker Compose (Production)
The recommended deployment method. Runs Rails, Go, PostgreSQL, Redis, and Sidekiq:
cd infra
docker compose -f docker-compose.prod.yml up -d
# Services:
# rails-api → port 4000 (Control Plane)
# core-go → port 8080 (Email Engine)
# postgres → port 5432
# redis → port 6379
# sidekiq → background jobsCloud Platforms
Pre-built deployment configs are included in the repo:
Fly.io
Deploy both services with edge networking.
fly.tomlRailway
One-click deploy with managed Postgres.
railway.jsonRender
Blueprint for web services + workers.
render.yamlRequired Environment Variables
Both services need these configured in production:
| Variable | Service | Description |
|---|---|---|
| DATABASE_URL | Both | PostgreSQL connection string |
| REDIS_URL | Rails | Redis for Sidekiq queue |
| JWT_SECRET | Rails | JWT signing secret |
| ENCRYPTION_KEY | Rails | AES-256 key for credential encryption |
| SECRET_KEY_BASE | Rails | Rails secret (128+ chars) |
| GO_CORE_URL | Rails | URL to Go service |
| GO_CORE_SECRET | Rails | Shared internal secret |
| INTERNAL_SECRET | Go | Must match GO_CORE_SECRET |
Self-Hosting
For VPS deployments, use Docker Compose with a reverse proxy:
# 1. Clone and configure
git clone https://github.com/courierx/courierx
cd courierx
cp .env.example .env # Configure your variables
# 2. Build and start
cd infra
docker compose -f docker-compose.prod.yml up -d
# 3. Run migrations
docker compose exec rails-api rails db:migrate
# 4. Set up Caddy (or nginx) as reverse proxy
# api.yourdomain.com → localhost:4000
# engine.yourdomain.com → localhost:8080 (optional)Contributing
CourierX is open source under the MIT license. Contributions are welcome.
Development Workflow
# 1. Fork and clone
git clone https://github.com/your-username/courierx
cd courierx
# 2. Start databases
./infra/scripts/setup-dev-light.sh
# 3. Rails setup
cd backend/control-plane
bundle install
RAILS_ENV=test bundle exec rails db:create db:migrate
bundle exec rspec # Run tests
bundle exec rubocop -A # Lint + auto-fix
# 4. Go setup
cd backend/core-go
go mod download
make check # fmt + lint + test-race
# 5. Create a PR
git checkout -b feature/my-change
# ... make changes ...
git push origin feature/my-changeCode Conventions
Rails
- Controllers inherit from
Api::V1::BaseController - Auth via
Authenticatableconcern - All UUIDs for primary keys
- RSpec + FactoryBot for tests
Go
- Providers implement
EmailProviderinterface - Classify errors as permanent/transient for failover
- Structured logging via slog
- Table-driven tests
Useful Make Targets
| Command | Description |
|---|---|
| bundle exec rspec | Run Rails tests |
| bundle exec rspec --fail-fast | Stop on first failure |
| bundle exec rubocop -A | Lint + auto-fix Ruby |
| make test | Run Go tests |
| make test-race | Go tests with race detector |
| make lint | Lint Go code |
| make check | fmt + lint + test-race (pre-commit) |
| make build | Build Go binary |