Sending Mail

Primitive sends outbound mail from verified identities. Use the CLI for operational workflows, SDKs for application code, and REST for runtimes where an SDK is not available.

Endpoint

All sends — with or without attachments — go to a single endpoint:

POST https://api.primitive.dev/v1/send-mail

Attachments are included inline (base64-encoded) in the same JSON body; there is no separate upload host. See Attachments for the size limits.

Send with CLI or cURL

primitive send \
  --to alice@example.com \
  --subject "Hello" \
  --body "Hi from Primitive" \
  --wait

The CLI can auto-resolve a verified from address for the organization. Use primitive send --help for all flags.

REST fields use snake_case. SDKs expose idiomatic names for each language, such as bodyText in TypeScript and BodyText in Go.

Recipient and Body Limits

to is a single recipient address on the public /v1/send-mail endpoint — a string, not an array. Send to multiple people by issuing one request per recipient. Exactly one of body_text or body_html is required (you may send both).

body_text and body_html together are capped at 256 KB (combined UTF-8 byte length). A request over the cap is rejected with a validation_error before the message is accepted. For larger payloads, send a link or an attachment instead.

SDK Send

import primitive from '@primitivedotdev/sdk';


const client = primitive.client({
  apiKey: process.env.PRIMITIVE_API_KEY!,
});


const result = await client.send({
  from: 'support@yourdomain.com',
  to: 'alice@example.com',
  subject: 'Order confirmed',
  bodyText: 'Your order is on its way.',
  wait: true,
});


console.log(result.id, result.deliveryStatus, result.smtpResponseCode);

Who You Can Send From

You can send from any verified domain on your organization with an active DKIM key. Managed *.primitive.email subdomains are verified by construction. Custom domains need outbound DNS records published and verified first.

Who You Can Send To

Primitive deliberately gates new outbound traffic. This protects deliverability and prevents a new account from becoming an unauthenticated bulk sender.

Allowed recipient categories include:

  • any address on a Primitive-managed zone such as *.primitive.email;
  • addresses on a custom domain verified by your organization;
  • email addresses of members of your Primitive organization;
  • addresses that previously sent you authenticated inbound mail, when replying to known addresses is allowed;
  • any domain after send_to_any_domain is enabled for your organization.

To inspect the active rules, call:

primitive sending:get-send-permissions

If a send is denied, Primitive returns 403 recipient_not_allowed with a structured gates array and per-gate fix.action hints. Branch on the error code, not on the human-readable message.

{
  "success": false,
  "error": {
    "code": "recipient_not_allowed",
    "message": "cannot send to alice@external.com",
    "gates": [
      {
        "name": "send_to_confirmed_domains",
        "reason": "domain_not_confirmed",
        "message": "external.com is not on this account's confirmed-domain list.",
        "subject": "external.com"
      },
      {
        "name": "send_to_known_addresses",
        "reason": "recipient_not_known",
        "message": "You have not exchanged authenticated mail with alice@external.com yet.",
        "subject": "alice@external.com",
        "fix": { "action": "wait_for_inbound", "subject": "alice@external.com" }
      }
    ],
    "request_id": "req_..."
  }
}

Each gate carries name, reason, message, and subject; only some gates carry a fix (an object of action + subject) — for example, send_to_known_addresses suggests wait_for_inbound, while send_to_confirmed_domains has no automatic fix. If no recipient gate is granted to your org at all, the response instead omits gates and carries details.required_entitlements listing the gates that would unlock sending.

Default send gates:

GateAllows
send_to_primitive_managed_domainsActive domains hosted on Primitive, including *.primitive.email.
send_to_confirmed_domainsAddresses at domains verified by your organization.
send_to_org_member_emailsEmail addresses owned by organization members.
send_to_known_addressesExternal addresses that previously sent you authenticated mail.
send_to_opted_in_domainsDomains that published an _agents opt-in record authorizing agents on Primitive.
send_to_any_domainAny recipient domain after broader sending is enabled.

Replying

Replying derives the recipient and threading headers from an inbound email.

await client.reply(email, {
  text: 'Got it.',
});

Use the CLI or raw API from terminal workflows:

primitive sending:reply-to-email \
  --id <inbound-email-id> \
  --body-text "Got it."

Primitive sets the Re: subject, In-Reply-To, and References headers from the parent message when available.

If the parent is not repliable, the API returns 422 inbound_not_repliable with a stable reason such as inbound_rejected, content_discarded, missing_message_id, or missing_recipient.

Forwarding

Forwarding sends a new outbound message that carries the context of an inbound email.

await client.forward(email, {
  to: 'oncall@example.com',
  bodyText: 'FYI, new bug report.',
});

Use forwarding for escalation, triage, and workflows where the final recipient is not the original sender.

Manual Threading

The reply and forward helpers set threading headers for you. When you build a send by hand and want it to thread into an existing conversation, pass the threading fields directly:

  • in_reply_to: the Message-ID of the message you are replying to;
  • references: the ordered chain of prior Message-IDs in the thread.
{
  "from": "support@yourdomain.com",
  "to": "alice@example.com",
  "subject": "Re: Order confirmed",
  "body_text": "Following up on your order.",
  "in_reply_to": "<abc123@example.com>",
  "references": ["<root@example.com>", "<abc123@example.com>"]
}

in_reply_to is a single Message-ID (at most 998 characters). references is an array of up to 100 Message-IDs whose combined length is at most about 8,000 characters. Values may not contain control characters. When you reply through /v1/emails/{id}/reply, Primitive derives these headers from the parent automatically, so manual threading is for sends you assemble yourself.

Custom Headers

Most message headers are derived by Primitive and cannot be overridden. A narrow allowlist of headers can be set per send through the headers map:

  • Auto-Submitted — mark auto-generated or auto-reply mail (RFC 3834);
  • X-Auto-Response-Suppress — suppress the recipient's own auto-replies to your message;
  • Precedence — legacy bulk/list/junk hint some receivers use for loop prevention.
{
  "from": "support@yourdomain.com",
  "to": "alice@example.com",
  "subject": "Receipt",
  "body_text": "Thanks for your order.",
  "headers": {
    "Auto-Submitted": "auto-generated",
    "X-Auto-Response-Suppress": "All"
  }
}

Header lookup is case-insensitive. You may set at most 25 entries; each key is at most 64 characters and each value is printable ASCII (no control characters) and at most 200 characters. Headers not on the allowlist — for example Return-Path, Sender, or List-Unsubscribe — are rejected with a validation_error naming the offending header. Threading headers are set with in_reply_to and references (above), not through this map.

Idempotency

Pass an idempotency key when retrying a send from your own infrastructure.

curl -X POST https://api.primitive.dev/v1/send-mail \
  -H "Authorization: Bearer $PRIMITIVE_API_KEY" \
  -H "Idempotency-Key: order-123-email-confirmation" \
  -H "Content-Type: application/json" \
  -d '{"from":"support@yourdomain.com","to":"alice@example.com","subject":"Confirmed","body_text":"Done"}'

The same key and canonical payload return the cached response instead of creating a second send. Reusing the same key with a different payload is rejected.

Wait Mode

wait: true makes the call block until Primitive has a delivery outcome from the receiving side. This is useful for agents, tests, and transactional workflows that need immediate status.

Without wait, the API returns after accepting the outbound message. Read later state from /v1/sent-emails or the SDK equivalent.

Wait responses can include status, delivery_status, smtp_response_code, smtp_response_text, and smtp_enhanced_status_code. delivery_status values are delivered, bounced, deferred, or wait_timeout; top-level status can also report states such as gate_denied or agent_failed.

The wait timeout defaults to 30000 ms. If you override it with wait_timeout_ms, use a value from 1000 to 30000 ms.

Response vs Stored Row

The SDK/API response and the durable sent_emails row can briefly disagree. With wait: true, Primitive returns the outbound agent's terminal delivery_status as soon as the SMTP response is known or the wait timeout elapses. The follow-up update that flips the stored row from queued to the same terminal status happens immediately after, but a transient write failure can push that catch-up onto a retry path.

Trust the send response for the per-call outcome. Treat GET /v1/sent-emails/{id} as the durable record that may converge a few seconds later.

const result = await client.send({
  from: 'support@example.com',
  to: 'alice@customer.com',
  subject: 'Order confirmed',
  bodyText: 'Your order is on its way.',
  wait: true,
});


console.log(result.deliveryStatus, result.smtpResponseCode);


// The stored row may briefly still show "queued" at this exact moment.

To watch the stored row catch up, poll GET /v1/sent-emails/{id} or use primitive sending:get-sent-email --id <send-id> until status reaches a terminal value.

Attachments

Attachments are sent inline in the JSON body. Each entry has a filename, an optional content_type (inferred from the filename when omitted), and a base64-encoded content_base64:

curl -X POST https://api.primitive.dev/v1/send-mail \
  -H "Authorization: Bearer $PRIMITIVE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "support@yourdomain.com",
    "to": "alice@example.com",
    "subject": "Report",
    "body_text": "Attached.",
    "attachments": [
      {
        "filename": "report.txt",
        "content_type": "text/plain",
        "content_base64": "cmVwb3J0Cg=="
      }
    ]
  }'

Limits per send:

  • up to 100 attachments;
  • 30 MiB total raw (decoded) across all attachments;
  • each content_base64 field is capped at ~42 MiB of base64 text;
  • filename and content_type reject control characters (header-injection guard).

Rate Limits

Outbound limits are plan and account dependent. When rate limited, /v1/send-mail returns 429 with the rate_limited error code, a structured error envelope, and a request id. Branch on the 429 status and honor Retry-After. See Errors.

Batch sending

Send many independent messages in one request with POST /v1/send-mail/batch. Each message runs through the exact same path as a single POST /v1/send-mail — same auth, validation, recipient gates, rate limiting, and idempotency — so the per-message semantics are identical. A batch is a convenience for fan-out, not a different send mode.

The body is a messages array, where each entry is a normal send payload (from, to, subject, body_text/body_html, attachments, headers, and so on):

curl -X POST https://api.primitive.dev/v1/send-mail/batch \
  -H "Authorization: Bearer $PRIMITIVE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "messages": [
      {
        "from": "support@yourdomain.com",
        "to": "alice@example.com",
        "subject": "Order confirmed",
        "body_text": "Your order is on its way."
      },
      {
        "from": "support@yourdomain.com",
        "to": "bob@example.com",
        "subject": "Order confirmed",
        "body_text": "Your order is on its way."
      }
    ]
  }'

A batch must contain between 1 and 100 messages; an empty messages array or one over the cap is rejected with a validation_error before any message is sent.

The response is a 200 envelope whose data carries count and a results array with one entry per input message, in order. One message failing does not fail the others — each entry independently reports success with either its own data (the per-send result) or an error:

{
  "success": true,
  "data": {
    "count": 2,
    "results": [
      { "index": 0, "success": true, "data": { "id": "..." } },
      { "index": 1, "success": false, "error": { "code": "recipient_not_allowed", "message": "..." } }
    ]
  }
}

Because the top-level status is 200 even when individual messages fail, always inspect each results entry's success flag rather than relying on the HTTP status. A batch-level Idempotency-Key header is not applied per message; each message instead derives its own idempotency from its payload, so set per-message keys by retrying individual sends if you need exactly-once guarantees.

Related Pages

  • Domains: configure a custom outbound identity.
  • REST API: endpoint list and envelope shape.
  • SDKs: high-level send, reply, and forward helpers.