Philosophy
A few design principles shape the ScaiSend API. Knowing them helps you predict how an unfamiliar endpoint will behave.
Compatibility over novelty#
The /v3/ surface matches SendGrid's v3 API on purpose. Same field names, same shapes, same HTTP codes, same 202-on-accept for /mail/send. When SendGrid's contract has a quirk — like from being a reserved Python keyword and the response sometimes returning message_id vs message_ids — ScaiSend keeps the quirk. You don't want a "cleaner" version of the SendGrid API; you want the SendGrid API, backed by infrastructure you control.
New capabilities that don't exist in SendGrid (image library, sender domain verification, admin endpoints) live under different path prefixes (/v3/images, /api/admin/) so they're clearly non-compatible.
Multi-tenant by default#
There is no single-tenant mode. Every request is resolved to a tenant — via the API key it was signed with, or via the JWT's tenant_id claim — before any business logic runs. Templates, suppressions, stats, messages, API keys, and webhook endpoints are all tenant-scoped. Cross-tenant data leakage isn't a bug class we're worried about; the database queries don't return rows from other tenants because the query predicate requires the active tenant.
If you're running a platform with customers, you don't need a second layer. Create a Tenant per customer, and the isolation is already there.
Async delivery, sync validation#
POST /v3/mail/send returns 202 Accepted after validating the request and queuing it to Redis. Actual SMTP delivery happens out-of-band, in the worker and SMTP services. Two consequences:
- What you can learn at send time: whether your request is malformed, whether you have permission, whether the template renders, whether you've exceeded rate limits. These return
4xxsynchronously. - What you can't learn at send time: whether the recipient's MX server accepts the message, whether it bounces, whether it gets marked as spam. These are observable only via webhooks or the message event timeline.
Design your integration assuming 202 means "accepted for processing," not "delivered." Use webhooks or the GET /v3/messages/{id} endpoint to confirm delivery.
Sandbox is an API key property#
SendGrid's sandbox mode is a per-request flag (mail_settings.sandbox_mode.enable: true). ScaiSend supports that flag but adds a stronger guarantee: test API keys (format sg_test_*) always force sandbox mode, regardless of the request body. This means your CI pipeline can hold a test key and you can't accidentally send real email even if a misconfigured env var leaks the flag.
Both behaviors work. Pick whichever fits your deployment — per-request for ad-hoc, per-key for environment isolation.
Opinionated defaults, explicit overrides#
ScaiSend ships with defaults that match what SendGrid customers expect:
- Open tracking and click tracking are on by default. Disable per-request (
tracking_settings.open_tracking.enable: false) or per-tenant (PUT /api/admin/tenants/{id}/tracking). - Subscription tracking is off by default; enable it when you need an unsubscribe footer.
- Image embedding uses
proxymode by default — images are served via/i/{image_id}so you get a backup open signal when the tracking pixel is blocked. Switch tocidif you'd rather embed images as attachments (bigger messages, no image-load tracking).
The tenant-level setting is the default; request-level settings override on a single send. Both layers are explicit; neither is inferred from your account tier.
Machine-readable where it matters#
Error responses follow either FastAPI's {"detail": "..."} shape (for routing and auth errors) or SendGrid's {"errors": [{"message": ..., "field": ...}]} shape (for /v3/mail/send validation). The HTTP status code classifies the error; the body gives details. Field-level validation errors identify the offending field so you can surface a precise message in your UI.
Events fanned out through webhooks are stable: processed, delivered, bounce, open, click, spam_report, unsubscribe, group_unsubscribe, group_resubscribe, deferred, dropped. Branch on the event type string, not on fields of the payload.
Self-hosting as the only mode#
There's no ScaiSend Cloud. Every instance is customer-operated, and every feature — including registration with ScaiKey, DKIM key generation, and bounce-inbound handling — has to work without phoning home. The CLI (scaisend register, scaisend sync --full) exists specifically so a fresh install can bootstrap itself against a ScaiKey instance and be sending within minutes.
What's next#
- Architecture — what the API, Worker, and SMTP services actually do.
- Quickstart — make the theory concrete.