API
Why Your App Should Send Email Through an API, Not Direct SMTP
Connecting to SMTP from application code spreads one password across every service and leaves pacing, retries, and bounce handling to you. Here's what an API boundary changes.
Published September 21, 2026
The default way to add email to an application is to open an SMTP connection from inside it: a nodemailer transport, a smtplib client, a mailer library reading SMTP_HOST and SMTP_PASSWORD from the environment. It works immediately, and it quietly makes your application the thing responsible for deliverability.
SMTP itself isn't the problem — GQ CRM sends over SMTP too. What matters is where the connection lives: platform code built for pacing and retries, or application code that already has other jobs.
What breaks when SMTP lives in your app
Credentials spread. Every service that can send — the API server, the background worker, the cron job, the one-off migration script — needs the mailbox password. Rotating it means redeploying all of them, and a leak in the least careful one exposes sending for every tenant on that mailbox.
Nothing coordinates pacing. Run three replicas and each one cheerfully sends its own share of the list at the same moment. Per-account rate limits only mean something when there is one shared counter, and an SMTP client inside your app has no idea what the other replicas are doing.
Retries are hand-rolled, so duplicates are normal. A timeout after the relay accepted the message is indistinguishable from a timeout before it did. Naive retry logic sends the order confirmation twice; no retry logic drops it.
Multi-tenant isolation becomes your problem. One mailbox shared by all customers means any code path that can reach the transport can send as any customer. Passing the right from address is not isolation.
Bounce and complaint feedback has nowhere to land. Hard bounces, soft bounces, complaints, and unsubscribes arrive asynchronously, usually as email into a mailbox nobody reads. Without that feedback inside your system you keep sending to dead addresses, and your reputation pays for it.
Your request handler waits on someone else's server. SMTP is synchronous and chatty. Doing it inline in a web request ties your latency — and your timeouts — to a relay you don't control.
What an API boundary changes
Calling POST /api/send instead of opening a connection moves those responsibilities to the platform:
- A key per project instead of a password per mailbox. The key identifies the project by itself, so there is no project ID to pass, and it can carry scopes (currently
send:email), an expiry date, and an IPv4/IPv6 CIDR allowlist. The SMTP password stays encrypted at rest and never reaches your application. - Pacing that accounts for every caller. Rate limits are counted per project per minute rather than per process, so replicas and background jobs share one budget. Plan tiers set the ceiling, and self-hosted deployments can override it.
- Quota guardrails before the send happens. An over-quota request fails with
403and adata.quotapayload showingcurrent,limit, andrequested— instead of quietly queueing mail you'll be billed for. - Delivery feedback as signed events.
email.sent,email.failed,email.bounced,email.complained, andemail.unsubscribedare pushed to your endpoint with an HMAC signature, retried on a backoff schedule, and deduplicated by delivery ID. - One place to look when something is wrong. Every accepted send writes a log entry with its own
messageId, so "did we email this customer?" has an answer.
Idempotency is the part people miss
Moving the connection doesn't fix duplicate sends by itself. What fixes it is a client-generated key per business event:
curl -X POST https://gq-crm.com/api/send \
-H "Content-Type: application/json" \
-H "X-API-Key: $GQ_CRM_API_KEY" \
-H "Idempotency-Key: order-2026-0001-confirm" \
-d '{"to":"[email protected]","subject":"Your order","html":"<p>Confirmed.</p>"}'
For 24 hours the same key replays the original response instead of sending again (marked with Idempotency-Replayed: true). Reuse the key with a different body and you get 409 with code = idempotency_key_reuse; fire a concurrent duplicate while the first is still in flight and you get 409 idempotency_in_progress. The practical result: derive the key from the business event, retry timeouts without fear, and let the platform decide whether the send already happened.
Errors you can branch on
Structured failures mean your code can react instead of logging a stack trace:
| Status | Meaning |
|---|---|
401 | Missing or invalid X-API-Key |
400 | Project has no SMTP configured, to/subject missing, or no content |
403 | feature_not_available (API access starts at Starter), quota_exceeded, expired, forbidden_scope, or ip_blocked |
404 | templateId doesn't belong to this project |
409 | Idempotency key reused with a different body, or the first request is still in flight |
429 | Per-project rate limit reached |
The code field separates cases that share a status: quota_exceeded is not feature_not_available, and neither is ip_blocked.
When direct SMTP from your app is still fine
Single-tenant internal tool, a handful of sends a day, no reputation to protect — an SMTP client in the app is a reasonable choice. The calculus changes with any of: multiple replicas, multiple tenants, customer-visible sends, or enough volume that one bad burst matters. Those are the conditions where the connection belongs somewhere that can pace it, retry it, and record it.
The GQ CRM approach
GQ CRM is a control plane over your own SMTP: connect SES, Mailgun, Google Workspace, or any relay per project, then send from your application with a project key instead of a mailbox password. Verification, rate limits, per-recipient tracking, bounce classification, and signed webhooks stay in the platform, and your SMTP bill stays yours.
Start with the Send API quickstart, then read Projects and API keys for the isolation model. If you're still deciding on a relay, How to Connect Amazon SES is the cheapest place to start.