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 handler
const 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>',
from: '[email protected]', // optional, must be a verified outbound address
});

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:

RuleAllows sending to
managed_zoneAnything at *.primitive.email. Always on.
send_to_confirmed_domainsAny address on a verified domain on your org.
send_to_known_addressesAny address that has previously sent DMARC-aligned mail to you. ("Reply to people who emailed you first.")
send_to_org_member_emailsEmail addresses of members of your Primitive org.
send_to_any_domainAnywhere. 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",
"message": "cannot send to [email protected]",
"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

LimitValue
Send rate (per org)1,000 / hour and 10,000 / day, sliding window
Body size256 KB combined body_text + body_html
wait_timeout_ms100 to 30,000 ms (default 5,000)
Subject / address header length998 octets (RFC 5322)
Recipient address length320 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

StatusCodeMeaning
400validation_errorBody failed Zod validation; field-level details in error.message.
401unauthorizedMissing or invalid Authorization: Bearer prim_… header.
403outbound_disabledOrg lacks the send_mail entitlement. New orgs hit this until outbound is enabled; reach out if you need it turned on.
403cannot_send_from_domainFrom-domain is not verified on this org or has no active DKIM key.
403recipient_not_allowedAll granted recipient gates denied. See error.gates[].
422inbound_not_repliableReply target was rejected, content-discarded, or missing required headers.
429rate_limit_exceeded1k/hr or 10k/day cap hit; check Retry-After.
502outbound_relay_failedUnderlying SMTP relay returned an unexpected error. Retryable.