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 accepts a single address string or an array of addresses. Each entry must be exactly one address; a comma-separated list inside one entry is rejected with a validation_error, so use the array form to address multiple recipients. The combined recipient count across to, cc, and bcc is capped at 100 per send. At least one of body_text or body_html is required.

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);

Multiple Recipients, Cc, and Bcc

A send with several recipients goes out as one message, not a fan-out into separate sends: every address in to, cc, and bcc receives the same SMTP message, and a single send record tracks the delivery.

{
  "from": "support@yourdomain.com",
  "to": ["alice@example.com", "bob@example.com"],
  "cc": "manager@example.com",
  "bcc": ["audit@yourdomain.com"],
  "subject": "Order confirmed",
  "body_text": "Your order is on its way."
}
  • cc and bcc take the same shape as to: a single address string or an array, with exactly one address per entry.
  • The combined to + cc + bcc count is capped at 100 recipients per send.
  • Every recipient, including cc and bcc, must pass the same recipient gates; one denied address rejects the whole send with 403 recipient_not_allowed naming that address.
  • bcc recipients receive the message, but the Bcc header is never included in the transmitted message, so other recipients cannot see them. The bcc list is stored on your own send record and read back on GET /v1/sent-emails/{id}.
  • The first to entry is the primary recipient: it fills the scalar to_address field in list responses, while the detail read-back also carries the full to_addresses, cc, and bcc arrays.

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.

Reply-To

reply_to sets the Reply-To header, directing replies somewhere other than the from address. It accepts a single address string or an array of up to 10 addresses. Each entry must be exactly one RFC 5322 mailbox; a display name is allowed.

{
  "from": "noreply@yourdomain.com",
  "to": "alice@example.com",
  "reply_to": ["Support <support@yourdomain.com>"],
  "subject": "Receipt",
  "body_text": "Thanks for your order."
}

Reply-To is a header, not a recipient: it adds no delivery, is not checked against recipient gates, and does not count toward the 100-recipient cap. The normalized addresses are read back as reply_to on GET /v1/sent-emails/{id}.

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 also replays the original outcome (no new send fires); the replayed response carries idempotent_replay: true and echoes the original content_hash, so compare it against your payload to detect the mismatch.

Tags

Attach up to 10 { "name": ..., "value": ... } tags to a send to correlate and filter your send history:

{
  "from": "support@yourdomain.com",
  "to": "alice@example.com",
  "subject": "Order confirmed",
  "body_text": "Your order is on its way.",
  "tags": [
    { "name": "category", "value": "order_confirmation" },
    { "name": "order_id", "value": "order_12345" }
  ]
}

Tags are metadata only. They never affect delivery, are never rendered into headers, and never appear on the transmitted message. They are stored on the send record and read back verbatim as tags on GET /v1/sent-emails/{id}.

Rules:

  • at most 10 tags per send;
  • names are capped at 64 characters and values at 256;
  • names and values both use an ASCII token charset: letters, digits, underscores, and dashes only, and neither may be empty (for a bare marker tag, supply an explicit value such as "true");
  • duplicate names are rejected, case-sensitively, so Env and env are two distinct names.

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).

Inline Images (content_id)

Give an attachment a content_id to embed it inline and reference it from body_html with a cid: URL:

{
  "from": "support@yourdomain.com",
  "to": "alice@example.com",
  "subject": "Welcome",
  "body_html": "<p>Hello</p><img src=\"cid:logo\" alt=\"Logo\">",
  "attachments": [
    {
      "filename": "logo.png",
      "content_type": "image/png",
      "content_id": "logo",
      "content_base64": "iVBORw0KGgo..."
    }
  ]
}

With content_id set, the part is composed inline (multipart/related, with a Content-ID header) so HTML-capable clients render it in place. Without it, the attachment behaves exactly as before. content_id must be 1-128 printable ASCII characters with no whitespace and no < or > (the composer adds the angle brackets); the cid: reference in your HTML must match it exactly.

Scheduled Sends

Pass scheduled_at to accept a send now and dispatch it later. The value is an ISO 8601 UTC datetime (Z suffix), strictly in the future, and at most 30 days out.

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": "Renewal reminder",
    "body_text": "Your plan renews tomorrow.",
    "scheduled_at": "2026-08-01T09:00:00Z"
  }'

The response mirrors an async send with status: "scheduled" and the requested scheduled_at echoed. Nothing is dispatched yet: queue_id is null and accepted/rejected are empty. Shortly after the scheduled time, Primitive dispatches the stored payload and the record moves through the normal statuses (queued, submitted_to_agent, then a delivery outcome).

scheduled_at cannot be combined with:

  • wait (a connection cannot be held open for a future send);
  • attachments (not supported on scheduled sends yet; send them at delivery time or use an immediate send).

Checks at Schedule Time vs Execution Time

Creating a scheduled send runs the same validation and permission checks as an immediate send, but as a preflight for early feedback only. Gates and quota are evaluated, and quota is consumed, at execution time: passing the preflight is not permission to send later. If, when the send comes due, the from-domain has lost sendability, a recipient is no longer allowed, the credential the send was scheduled with has been revoked, or a usage limit is exhausted, the record lands at gate_denied with the denial recorded in error_code and error_message, and is not retried. Send rate limits are the exception: creating the scheduled send consumes your send budget at schedule time, not at execution.

Rescheduling and Canceling

Move a still-scheduled send with PATCH /v1/sent-emails/{id} (same bounds: strictly future, at most 30 days out), or cancel it with POST /v1/sent-emails/{id}/cancel. Both return the updated sent-email record. Cancellation is terminal (status: "canceled"); a canceled send never dispatches.

curl -X PATCH https://api.primitive.dev/v1/sent-emails/<send-id> \
  -H "Authorization: Bearer $PRIMITIVE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"scheduled_at": "2026-08-02T09:00:00Z"}'


curl -X POST https://api.primitive.dev/v1/sent-emails/<send-id>/cancel \
  -H "Authorization: Bearer $PRIMITIVE_API_KEY"

Both operations succeed only while the record is still scheduled. Once execution has claimed it, or it was already canceled or executed, they return 409 with the not_scheduled error code and a message naming the current status. Exactly one of reschedule, cancel, or execution wins on any given record.

Scheduled and canceled sends are visible on GET /v1/sent-emails (filter with status=scheduled or status=canceled). Records keep scheduled_at after execution as the historical schedule, and canceled records carry canceled_at.

Two idempotency notes specific to scheduling: scheduled_at is part of the request's content hash, so the same payload scheduled for two different times creates two distinct sends, while a retry of an identical scheduled request replays the original. Rescheduling does not recompute that hash, so a fresh POST /v1/send-mail with the same payload and the record's new time creates a second, separate send rather than replaying the rescheduled one.

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.
  • Simple Email: a drop-in send surface compatible with a widely used JavaScript email SDK's wire format.