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.

Base URLs

Use the default API host for normal JSON sends:

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

Use the high-body-cap attachment host when the request includes an attachments array:

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

Auth and body shape are identical to the JSON send-mail endpoint.

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.

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",
        "fix": {
          "action": "wait_for_inbound",
          "subject": "alice@external.com",
          "message": "Once alice@external.com sends you authenticated mail, the known-address gate lets you reply."
        }
      }
    ],
    "request_id": "req_...",
    "details": {
      "required_entitlements": [
        "send_to_any_domain",
        "send_to_primitive_managed_domains",
        "send_to_confirmed_domains",
        "send_to_known_addresses",
        "send_to_org_member_emails"
      ]
    }
  }
}

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

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

For attachment sends, use the attachment host:

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=="
      }
    ]
  }'

Rate Limits

Outbound limits are plan and account dependent. When rate limited, Primitive returns 429 rate_limit_exceeded with a structured error envelope and request id.

Related Pages

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