Documentation

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

apiKey

AWS SES

accessKeyId, secretAccessKey, region

Mailgun

apiKey, domain, region

Postmark

serverToken

Resend

apiKey

SMTP

host, port, user, pass

Getting 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:8080

Native 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 sidekiq

Environment 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-secret

Go Core Engine

DATABASE_URL=postgresql://...
INTERNAL_SECRET=shared-secret-here
PORT=8080
LOG_LEVEL=info
MAX_WORKERS=100
ENABLE_METRICS=true
IDEMPOTENCY_TTL=86400

Send 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:

HeaderDescription
X-RateLimit-LimitMax requests per minute
X-RateLimit-RemainingRequests remaining in window
X-RateLimit-ResetUnix timestamp when limit resets
X-Total-CountTotal records (on list endpoints)
X-Page / X-Per-PagePagination 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

POST/api/v1/auth/register

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..."
}
POST/api/v1/auth/login

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/api/v1/auth/me

Get the current tenant profile.

PATCH/api/v1/auth/me

Update tenant name or settings.

DELETE/api/v1/auth/me

Delete tenant account. Returns 204.

Emails

POST/api/v1/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.

GET/api/v1/emails

List emails with optional filters. Paginated (default 25, max 100 per page).

ParameterDescription
statusFilter: queued, sent, delivered, bounced, failed, suppressed
recipientPartial match on to_email
fromStart date (ISO 8601)
toEnd date (ISO 8601)
pagePage number (default 1)
per_pageResults per page (default 25, max 100)
GET/api/v1/emails/:id

Get a single email with its full event timeline (delivered, bounced, opened, clicked, etc.).

API Keys

POST/api/v1/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
}
GET/api/v1/api_keys

List all API keys (raw key is never returned again).

PATCH/api/v1/api_keys/:id/revoke

Revoke an API key. Sets status to "revoked".

DELETE/api/v1/api_keys/:id

Permanently delete an API key. Returns 204.

Provider Connections

GET/api/v1/provider_connections

List all provider connections, sorted by priority.

POST/api/v1/provider_connections

Connect a new provider with credentials. Credentials are encrypted before storage. Triggers automatic verification.

PATCH/api/v1/provider_connections/:id

Update connection settings or rotate credentials.

POST/api/v1/provider_connections/:id/verify

Re-verify provider credentials.

DELETE/api/v1/provider_connections/:id

Remove a provider connection. Returns 204.

Domains

GET/api/v1/domains

List all sending domains with verification status.

POST/api/v1/domains

Register a new sending domain. Returns a verification token for DNS.

POST/api/v1/domains/:id/verify

Trigger DNS verification for a domain.

DELETE/api/v1/domains/:id

Remove a domain. Returns 204.

Routing Rules

GET/api/v1/routing_rules

List all routing rules.

POST/api/v1/routing_rules

Create a routing rule. Strategies: priority, weighted, round_robin, failover_only.

PATCH/api/v1/routing_rules/:id

Update a routing rule.

DELETE/api/v1/routing_rules/:id

Delete a routing rule. Returns 204.

Suppressions

GET/api/v1/suppressions

List suppressed emails. Filter by reason: bounce, complaint, unsubscribe, manual.

POST/api/v1/suppressions

Add an email to the suppression list.

DELETE/api/v1/suppressions/:id

Remove a suppression. Returns 204.

Webhook Endpoints

GET/api/v1/webhook_endpoints

List all registered webhook endpoints.

POST/api/v1/webhook_endpoints

Register a webhook URL. A secret is auto-generated for HMAC payload signing.

PATCH/api/v1/webhook_endpoints/:id

Update a webhook endpoint.

DELETE/api/v1/webhook_endpoints/:id

Remove a webhook endpoint. Returns 204.

Dashboard & Analytics

GET/api/v1/dashboard/metrics

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/api/v1/usage_stats

Get daily usage statistics. Query params: from, to (ISO dates).

GET/api/v1/dashboard/compliance

Get compliance trust score and DNS verification status for DKIM, SPF, DMARC.

Health Checks

GET/api/v1/health

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 }
  }
}
GET/health/ready

Go engine readiness probe (checks database).

GET/health/live

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.

POST/v1/send

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
}
POST/v1/send/batch

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": [ ... ]
}
POST/internal/verify-provider

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/node
npm install @courierx/node
import { 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 jobs

Cloud Platforms

Pre-built deployment configs are included in the repo:

Fly.io

Deploy both services with edge networking.

fly.toml

Railway

One-click deploy with managed Postgres.

railway.json

Render

Blueprint for web services + workers.

render.yaml

Required Environment Variables

Both services need these configured in production:

VariableServiceDescription
DATABASE_URLBothPostgreSQL connection string
REDIS_URLRailsRedis for Sidekiq queue
JWT_SECRETRailsJWT signing secret
ENCRYPTION_KEYRailsAES-256 key for credential encryption
SECRET_KEY_BASERailsRails secret (128+ chars)
GO_CORE_URLRailsURL to Go service
GO_CORE_SECRETRailsShared internal secret
INTERNAL_SECRETGoMust 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-change

Code Conventions

Rails

  • Controllers inherit from Api::V1::BaseController
  • Auth via Authenticatable concern
  • All UUIDs for primary keys
  • RSpec + FactoryBot for tests

Go

  • Providers implement EmailProvider interface
  • Classify errors as permanent/transient for failover
  • Structured logging via slog
  • Table-driven tests

Useful Make Targets

CommandDescription
bundle exec rspecRun Rails tests
bundle exec rspec --fail-fastStop on first failure
bundle exec rubocop -ALint + auto-fix Ruby
make testRun Go tests
make test-raceGo tests with race detector
make lintLint Go code
make checkfmt + lint + test-race (pre-commit)
make buildBuild Go binary