Last updated: 2026-03-03•5 min read

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

FieldTypeRequiredDescription
tostring | string[]YesRecipient address(es)
fromstringYesVerified sender address
subjectstringYesEmail subject
htmlstringOne of html/textHTML body
textstringOne of html/textPlain text body
reply_tostringNoReply-To address
attachmentsAttachment[]NoFile attachments
headersobjectNoCustom 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.

StageSend attemptsDelaysAfter the last attempt
Retry ladder3 (first send plus 2 retries)30s, 2mHands over to the failed queue
Failed queue530s, 2m, 8m, 30m, 2hDead-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).

StatusCodeDescription
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.
403SCOPE_REQUIREDThe API key lacks the send scope.
403DOMAIN_PENDING_APPROVALThe sending domain is new and waits for review by Truncus, usually within one working day.
403SANDBOX_RECIPIENT_NOT_ALLOWEDThe shared test domain mail.truncus.co was aimed at an address outside your account.
403OUTREACH_NOT_AVAILABLEThe send was declared as outreach; Truncus is for transactional email only.
403DOMAIN_TYPE_MISMATCHThe message's type does not match the sending domain's type.
429—Rate limit exceeded. See the X-RateLimit-* headers.
500—Internal server error.
502ATTACHMENTS_NOT_RETRIEDEmail provider error (SES). An email with attachments whose send failed temporarily is not retried automatically: resend it with the same Idempotency-Key.
503DOMAIN_BREAKER_OPENThe 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
}
POST /api/v1/emails/send | Truncus Manual