POST /v1/emails/send
Send a transactional email. /emails/send is the asynchronous path: it returns when a provider accepts or refuses the message, not when the message is delivered. For the terminal state in the same request, use /emails/send_sync.
Request
POST https://truncus.co/api/v1/emails/send
Authorization: Bearer tr_live_...
Content-Type: application/json
Idempotency-Key: your-idempotency-key (required)
Body
{
"to": "user@example.com",
"from": "hello@yourdomain.com",
"subject": "Invoice ready",
"html": "<p>Your invoice is attached.</p>",
"text": "Your invoice is attached.",
"reply_to": "support@yourdomain.com",
"attachments": [
{
"filename": "invoice.pdf",
"content": "base64-encoded-content"
}
],
"headers": {
"X-Entity-Ref-ID": "your-internal-id"
}
}
Fields
| Field | Type | Required | Description |
|---|---|---|---|
to | string | string[] | Yes | Recipient address(es) |
from | string | Yes | Verified sender address |
subject | string | Yes | Email subject |
html | string | One of html/text | HTML body |
text | string | One of html/text | Plain text body |
reply_to | string | No | Reply-To address |
attachments | Attachment[] | No | File attachments |
headers | object | No | Custom email headers |
Response
status is one of sent (accepted by a provider), queued (transient error, retry scheduled), scheduled (deferred via send_at), duplicate (idempotency replay), blocked (suppressed or domain type mismatch) or failed (permanent error). Delivery, bounce and complaint arrive later as webhook events.
200 — sent
{
"status": "sent",
"message_id": "msg_8f21a3b4",
"global_message_id": "gm_8f21a3b4",
"provider": "ses",
"provider_message_id": "0102018f..."
}
200 — queued
{
"status": "queued",
"message_id": "msg_9b44c1e2",
"retry_scheduled": true,
"retry_at": "2026-10-24T09:12:33.000Z"
}
200 — blocked
{
"status": "blocked",
"message_id": "msg_a2f7e8d1",
"reason": "All recipients are suppressed",
"code": "ALL_RECIPIENTS_SUPPRESSED"
}
Terminal state in one request: send_sync
Same body, one recipient, no cc/bcc, no send_at. The call holds the connection for up to 25 seconds until SES reports a delivery, bounce or complaint, and returns that terminal state: delivered, bounced or complained. If no event arrives in that window, it returns pending with the message_id to poll via GET /api/v1/emails/{id}.
POST https://truncus.co/api/v1/emails/send_sync
200 — delivered
{
"message_id": "cmvxr6q4q000120ajvvjpxhq8",
"global_message_id": "gm_8b96f356-f7e9-472e-bbe4-88a9be1651b5",
"status": "delivered",
"delivered_at": "2026-11-01T11:35:11.909Z",
"smtp_response": "250 2.0.0 Ok: queued as 4jHXnV4rtkz9vNPx",
"latency_ms": 2080
}
200 — bounced
{
"status": "bounced",
"message_id": "msg_9b44c1e2",
"global_message_id": "gm_3e1f7a20-6c4d-4b19-9a52-d07c8e41b6f3",
"bounced_at": "2026-10-24T09:12:04.077Z",
"latency_ms": 2310
}
200 — pending
{
"status": "pending",
"message_id": "msg_a2f7e8d1",
"global_message_id": "gm_c47d2e91-0b8a-4f63-b1e5-72a9f3d6e018",
"note": "Delivery confirmation not received within 25s. The email was sent successfully. Check status via GET /api/v1/emails/msg_a2f7e8d1",
"latency_ms": 25004
}
200 — sandbox
With the header X-Truncus-Sandbox: true, send_sync reaches no provider and does not wait for an event. It returns at once with status: "sent", not delivered, plus sandbox: true and provider_message_id: null. A warnings array appears only when there are warnings. Replaying the same Idempotency-Key returns the duplicate response.
{
"status": "sent",
"message_id": "msg_7c31d9f0",
"global_message_id": "gm_5d0e8b12-93a7-4c6e-8f21-1be47a60c9d4",
"provider_message_id": null,
"sandbox": true
}
Retries and the failed state
A temporary send failure returns queued. Truncus then retries in two stages, one after the other.
| Stage | Send attempts | Delays | After the last attempt |
|---|---|---|---|
| Retry ladder | 3 (first send plus 2 retries) | 30s, 2m | Hands over to the failed queue |
| Failed queue | 5 | 30s, 2m, 8m, 30m, 2h | Dead-letter, and the failed webhook fires |
The total is about 2h43m nominal. Actual timing runs later, because the drains are cron-driven (5-minute and 2-minute intervals) and the ladder has up to 10% jitter.
Error responses
Both endpoints return the same errors. Every error body has error (human-readable) and code (stable machine code, where one is listed).
| Status | Code | Description |
|---|---|---|
| 400 | — | Validation error: missing fields, bad domain, send_at in the past. On send_sync also: more than one recipient, or a scheduled send. |
| 401 | — | Missing or invalid API key. |
| 403 | SCOPE_REQUIRED | The API key lacks the send scope. |
| 403 | DOMAIN_PENDING_APPROVAL | The sending domain is new and waits for review by Truncus, usually within one working day. |
| 403 | SANDBOX_RECIPIENT_NOT_ALLOWED | The shared test domain mail.truncus.co was aimed at an address outside your account. |
| 403 | OUTREACH_NOT_AVAILABLE | The send was declared as outreach; Truncus is for transactional email only. |
| 403 | DOMAIN_TYPE_MISMATCH | The message's type does not match the sending domain's type. |
| 429 | — | Rate limit exceeded. See the X-RateLimit-* headers. |
| 500 | — | Internal server error. |
| 502 | ATTACHMENTS_NOT_RETRIED | Email provider error (SES). An email with attachments whose send failed temporarily is not retried automatically: resend it with the same Idempotency-Key. |
| 503 | DOMAIN_BREAKER_OPEN | The sending domain's circuit breaker is open. Nothing was sent; send again with the same Idempotency-Key after Retry-After seconds. |
Error shape
{
"error": "Sending from this domain is temporarily halted by the circuit breaker",
"code": "DOMAIN_BREAKER_OPEN",
"message_id": "msg_a2f7e8d1",
"retry_after": 60
}
SDK example
import { Truncus } from '@truncus/email'
const truncus = new Truncus({ apiKey: process.env.TRUNCUS_API_KEY })
const response = await truncus.emails.send({
to: 'user@example.com',
from: 'billing@yourdomain.com',
subject: 'Invoice ready',
html: '<p>Your invoice is ready.</p>'
})
if (response.status === 'sent') {
// accepted by a provider — delivery or bounce arrives as a webhook event
} else if (response.status === 'queued') {
// transient error — Truncus retries at response.retry_at
} else if (response.status === 'blocked') {
// do not retry — suppressed address or domain type mismatch
}