Sending mail
Send a message via POST /api/v1/send-mail with a Bearer prim_ key. The same endpoint handles fresh sends; reply and forward have their own helpers.
import primitive from '@primitivedotdev/sdk';const client = primitive.client({ apiKey: process.env.PRIMITIVE_API_KEY! });const result = await client.send({subject: 'Order confirmed',bodyText: 'Your order is on its way.',wait: true,});console.log(result.id, result.deliveryStatus, result.smtpResponseCode);
Async vs synchronous
Set wait: true to block the request until the recipient's mail server responds (up to wait_timeout_ms, default 5000, max 30000). The response comes back with delivery_status, smtp_response_code, smtp_response_text, and smtp_enhanced_status_code ( RFC 3463).
Without wait, you get status: "submitted_to_agent" immediately and the delivery resolves in the background. You can poll GET /api/v1/sent-emails/{id} for the terminal status.
Terminal statuses: delivered, bounced, deferred, wait_timeout, gate_denied, agent_failed.
Replying to inbound
Pass a parsed inbound email to client.reply() and Primitive does the threading work:
// inside a webhook handlerconst email = await primitive.receive(req, {secret: process.env.PRIMITIVE_WEBHOOK_SECRET!,});// Recipient, threading, "Re:" subject all derived from the parent.await client.reply(email, 'Thanks for getting in touch.');// You can also pass an object to override or add HTML:await client.reply(email, {text: 'Plain text body.',html: '<p>HTML body.</p>',});
Server-derived: to, subject, In-Reply-To, References. Overridable: body_text, body_html, from (any verified outbound address on the same org).
If the parent isn't repliable, the API returns 422 inbound_not_repliable with a stable reason code: inbound_rejected, content_discarded, missing_message_id, or missing_recipient.
Forwarding
client.forward() ships an inbound message to a new recipient, optionally wrapped with your own note:
await client.forward(email, {bodyText: 'FYI, new bug report.',});
Who you can send from
Any verified domain on your org with an active DKIM key. Managed *.primitive.email subdomains are verified by construction; BYO domains need outbound DNS published and verified once.
Who you can send to
Recipients are gated by per-org entitlements. The GET /api/v1/send-permissions endpoint and POST /api/v1/sendability return the rules in effect for your account. The default rules:
| Rule | Allows sending to |
|---|---|
managed_zone | Anything at *.primitive.email. Always on. |
send_to_confirmed_domains | Any address on a verified domain on your org. |
send_to_known_addresses | Any address that has previously sent DMARC-aligned mail to you. ("Reply to people who emailed you first.") |
send_to_org_member_emails | Email addresses of members of your Primitive org. |
send_to_any_domain | Anywhere. Available on request. |
When all granted gates deny a recipient, the response is 403 recipient_not_allowed with structured per-gate detail in error.gates[]:
{"success": false,"error": {"code": "recipient_not_allowed","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"}],"request_id": "f0d…","details": {"required_entitlements": ["send_to_any_domain", "send_to_confirmed_domains", "send_to_known_addresses", "send_to_org_member_emails"]}}}
Threading
For free-form sends, set in_reply_to (an RFC 5322 Message-ID like <[email protected]>) and references (an array of Message-IDs). Primitive emits matching In-Reply-To and References headers so receiving clients (Gmail, Apple Mail, Outlook) thread the message correctly. The client.reply() helper does this automatically from the parent inbound email.
Idempotency
Pass an Idempotency-Key header (printable ASCII, ≤ 255 chars). If absent, Primitive derives a key from a SHA-256 hash of the canonical body. Same key + same payload returns the cached response with idempotent_replay: true; the effective key is echoed in the response Idempotency-Key header. Gate denials are not cached.
Limits
| Limit | Value |
|---|---|
| Send rate (per org) | 1,000 / hour and 10,000 / day, sliding window |
| Body size | 256 KB combined body_text + body_html |
wait_timeout_ms | 100 to 30,000 ms (default 5,000) |
| Subject / address header length | 998 octets (RFC 5322) |
| Recipient address length | 320 chars (RFC 5321) |
Hitting the rate limit returns 429 rate_limit_exceeded with a Retry-After header in seconds.
Authentication headers
Every outbound message is DKIM-signed with the active key for the From-domain. The selector and signing domain are persisted on each sent-email row and exposed via GET /api/v1/sent-emails/{id} as dkim_selector and dkim_domain.
For full DMARC alignment on a BYO domain, publish your SPF, DMARC, and TLS-RPT records along with the DKIM TXT. The dashboard generates them all. See Domains.
Error codes
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | Body failed Zod validation; field-level details in error.message. |
| 401 | unauthorized | Missing or invalid Authorization: Bearer prim_… header. |
| 403 | outbound_disabled | Org lacks the send_mail entitlement. New orgs hit this until outbound is enabled; reach out if you need it turned on. |
| 403 | cannot_send_from_domain | From-domain is not verified on this org or has no active DKIM key. |
| 403 | recipient_not_allowed | All granted recipient gates denied. See error.gates[]. |
| 422 | inbound_not_repliable | Reply target was rejected, content-discarded, or missing required headers. |
| 429 | rate_limit_exceeded | 1k/hr or 10k/day cap hit; check Retry-After. |
| 502 | outbound_relay_failed | Underlying SMTP relay returned an unexpected error. Retryable. |