CapiMail API quickstart
The transactional email API is available at /api/v1/emails. These examples describe the implemented runtime contract. Start with a cm_test_ key: a valid test request is validated without creating a message, reserving credits, or sending email.
Authentication
Send the complete API key as a Bearer token. Keys belong to one organization, can be revoked or expired, and must remain in a server-side secret manager. Never expose one in browser code or a public repository.
Authorization: Bearer cm_test_your_key
Authorization: Bearer cm_live_your_keyPOST /api/v1/emails requires email:send. GET /api/v1/emails/{message_id} accepts email:read or email:send. Invalid, expired, or revoked credentials return 401 unauthorized; a valid key without a required scope returns 403 insufficient_scope.
Send an email
A live request accepts 1–50 unique recipients and requires a verified sender domain, an approved organization, sufficient credits, and either html or text content.
curl -X POST https://capimail.com/api/v1/emails \
-H "Authorization: Bearer cm_test_your_key" \
-H "Content-Type: application/json" \
-d '{
"from": "hello@example.com",
"to": ["customer@example.net"],
"subject": "Welcome!",
"html": "<h1>Welcome</h1>",
"idempotency_key": "welcome-customer-123"
}'Live-key response · HTTP 202 Accepted
HTTP/1.1 202 Accepted
Content-Type: application/json
Cache-Control: no-store
{
"message_id": "uuid",
"status": "queued",
"request_id": "uuid"
}A 202 means the send is durably persisted with a credit reservation and a queued job; it does not mean delivered. Background processing performs the provider attempt. A repeated idempotency key returns the original message instead of sending twice, and provider acceptance is not the same as confirmed delivery.
Test-key response · HTTP 200 OK
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store
{
"status": "validated",
"delivered": false,
"message": "Request is valid. Test keys never queue, reserve credits, or deliver email.",
"request_id": "uuid"
}A test-key request creates no message, consumes no credits, and never reaches the delivery worker.
Integration examples
JavaScript / TypeScript
const response = await fetch("https://capimail.com/api/v1/emails", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.CAPIMAIL_API_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ from: "hello@your-verified-domain.com", to: ["customer@example.com"], subject: "Account update", text: "Your account was updated.", idempotency_key: "account-update-123" })
});
if (!response.ok) throw new Error(JSON.stringify(await response.json()));Node.js
import https from "node:https";
// Use your preferred HTTP client and keep CAPIMAIL_API_KEY server-side.
// POST the same JSON contract to /api/v1/emails with the Bearer header.Python
import os, requests
r = requests.post("https://capimail.com/api/v1/emails",
headers={"Authorization": f"Bearer {os.environ['CAPIMAIL_API_KEY']}"},
json={"from":"hello@your-verified-domain.com","to":["customer@example.com"],"subject":"Account update","text":"Your account was updated.","idempotency_key":"account-update-123"})
r.raise_for_status()Retrieve email status
curl https://capimail.com/api/v1/emails/{message_id} \
-H "Authorization: Bearer cm_live_your_key"Possible states are queued, processing, provider_accepted, delivered, soft_bounced, hard_bounced, complained, failed, and unknown. Only an authenticated provider delivery event means delivered.
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store
{
"email": {
"id": "uuid",
"status": "delivered",
"sender": "hello@your-verified-domain.com",
"subject": "Account update",
"recipient_count": 1,
"provider_message_id": "provider-id",
"created_at": "2026-10-14T10:00:00.000Z",
"events": [
{ "event_type": "delivery", "occurred_at": "2026-10-14T10:00:03.000Z" }
]
},
"request_id": "uuid"
}Idempotency
Every send requires an idempotency_key of 8–128 characters using letters, numbers, dots, underscores, colons, or hyphens. Keep one key for one logical email and reuse it when retrying an uncertain request.
"idempotency_key": "invoice-paid:invoice_123"Repeating an accepted key with the identical payload returns 202, the original message_id, and idempotent: true. Reusing that key with a different sender, recipient, subject, content, headers, or tags returns 409 idempotency_conflict.
HTTP/1.1 202 Accepted
Content-Type: application/json
Cache-Control: no-store
{
"status": "queued",
"message_id": "original-uuid",
"provider_message_id": null,
"idempotent": true,
"request_id": "uuid"
}Errors
API errors contain a stable machine-readable code, a safe message, and a top-level request_id for support and tracing.
HTTP/1.1 422 Unprocessable Content
Content-Type: application/json
Cache-Control: no-store
{
"status": "error",
"error": {
"code": "validation_error",
"message": "Request validation failed.",
"details": [{ "path": "to.0", "message": "Invalid email address" }]
},
"request_id": "uuid"
}| HTTP | Machine code | Endpoint | Meaning |
|---|---|---|---|
| 400 | invalid_json | POST | The request body is not valid JSON. |
| 401 | unauthorized | POST / GET | The API key is missing, invalid, expired, or revoked. |
| 402 | insufficient_credits | POST | The organization does not have enough available credits. |
| 403 | insufficient_scope | POST / GET | The key does not grant a required email scope. |
| 403 | organization_not_approved | POST | The organization is not approved for live sending. |
| 403 | domain_not_verified | POST | The sender domain is not verified. |
| 404 | not_found | GET | No tenant-scoped message exists with that ID. |
| 409 | idempotency_conflict | POST | The idempotency key was reused with a different payload. |
| 413 | payload_too_large | POST | The request exceeds the maximum accepted payload size. |
| 422 | validation_error | POST | One or more request fields are invalid. |
| 422 | recipient_suppressed | POST | One or more recipients are on the suppression list. |
| 429 | rate_limit_exceeded | POST | A key or organization recipient limit was exceeded. |
| 500 | backend_error | POST / GET | A configured backend operation failed. |
| 503 | backend_not_configured | POST / GET | The email backend is unavailable or incomplete. |
Every public v1 response carries its numeric result in the HTTP status line and includes a traceable request_id in the JSON body. Error responses also include a stable machine-readable identifier in error.code. Clients should branch on the HTTP status or response.ok, then use error.code for application-specific handling.
Rate limits
Limits are enforced atomically by recipient count, independently for each API key and organization. The default deployment limits are 30 recipients per key per minute, 5 recipients per organization per minute, and 20 recipients per organization per UTC day; operators can change these values through environment configuration.
When any window would be exceeded, the request returns 429 rate_limit_exceeded. Wait until the relevant minute or UTC-day window resets, then retry with the same idempotency key. Invalid requests and test-key validation do not consume a rate-limit slot or credits. The introductory allowance is 100 accepted recipient sends once per eligible organization and does not reset monthly.
Domain verification and events
Add a domain in the dashboard, publish every DKIM record exactly, configure SPF/DMARC for your policy, then request a recheck. CapiMail verifies status through AWS SES; typing a domain never marks it verified. SES notifications are signature-checked, allow-listed by topic, processed idempotently, and used to advance delivery, bounce, complaint, and suppression state.