Inbound and Outbound Email Model
The normalized email object and the receive/send/reply/forward flow that every Primitive SDK implements identically, including wait-mode delivery statuses.
Primitive is an inbound and outbound email platform. Every SDK (Node.js, Python, Go) implements the same small model: verify and normalize an inbound webhook into a ReceivedEmail, then send, reply to, or forward mail through a client that optionally waits for delivery confirmation. This page defines that model once; each SDK's own docs show its exact syntax.
The four-step flow#
Every integration follows the same four steps: receive an inbound webhook, normalize it, reply or forward, and send new mail when you're not responding to anything.
- Receive an inbound webhook delivery and verify its HMAC signature.
- Normalize it into a
ReceivedEmailobject with a consistent field shape. - Reply or forward using fields the server derives for you (threading, subject, recipients).
- Send new outbound mail directly when you're not responding to an inbound message.
This is the same model across languages:
primitive.receive(...) → client.reply(email, ...)
primitive.receive(...) → client.reply(email, ...)
primitive.Receive(...) → client.Reply(ctx, email, ...)
The normalized ReceivedEmail object#
ReceivedEmail is the SDK-normalized representation of an inbound email. It is what receive() / Receive() returns after verifying the webhook signature and parsing the raw payload. It keeps the common case clean while preserving the full raw payload for advanced use.
Every SDK exposes the same fields, just spelled in the language's own casing convention:
| Concept | Node.js | Python | Go |
|---|---|---|---|
| Sender address | email.sender.address | email.sender.address | email.Sender.Address |
| Recipient that received it | email.receivedBy | email.received_by | email.ReceivedBy |
| Address to reply to | email.replyTarget.address | email.reply_target.address | email.ReplyTarget.Address |
Reply subject (Re: ...) | email.replySubject | email.reply_subject | email.ReplySubject |
Forward subject (Fwd: ...) | email.forwardSubject | email.forward_subject | email.ForwardSubject |
| Subject | email.subject | email.subject | email.Subject |
| Body text | email.text | email.text | email.Text |
| Threading message-id | email.thread.messageId | email.thread.message_id | email.Thread.MessageID |
| Threading references | email.thread.references | email.thread.references | email.Thread.References |
| Raw webhook payload | email.raw | email.raw | email.Raw |
email.raw (Node/Python) or email.Raw (Go) is the original, schema-validated email.received event (EmailReceivedEvent), the raw payload distinct from the normalized object above. Fall back to it when you need a field ReceivedEmail doesn't surface, such as SPF/DKIM/DMARC results for email authenticity checks.
Don't authorize actions based on email.replyTarget / email.reply_target / email.ReplyTarget, or on the raw SMTP envelope sender. Both are sender-controlled. Use domain-anchored sender trust for anything security-sensitive.
Receiving inbound mail#
Call receive() (Node/Python) or Receive() (Go) with the raw body, headers, and your webhook secret; it verifies the signature and returns a ReceivedEmail.
import primitive from "@primitivedotdev/sdk";
export const runtime = "nodejs";
export const maxDuration = 300;
const client = primitive.client({
apiKey: process.env.PRIMITIVE_API_KEY!,
});
export async function POST(req: Request) {
const email = await primitive.receive(req, {
secret: process.env.PRIMITIVE_WEBHOOK_SECRET!,
});
await client.reply(email, "Thank you for your email.");
return Response.json({ ok: true });
}
import primitive
client = primitive.client(api_key="prim_test")
def webhook_handler(body: bytes, headers: dict[str, str]) -> dict[str, object]:
email = primitive.receive(
body=body,
headers=headers,
secret="whsec_...",
)
client.reply(email, "Thank you for your email.")
return {"ok": True}
email, err := primitive.Receive(primitive.HandleWebhookOptions{
Body: body,
Headers: headers,
Secret: "whsec_...",
})
if err != nil {
log.Printf("invalid webhook: %v", err)
return
}
client, err := primitive.NewClient("prim_test")
if err != nil {
log.Fatal(err)
}
_, err = client.Reply(ctx, email, primitive.ReplyParams{BodyText: "Thank you for your email."})
receive() / Receive() reads the body, verifies the Primitive-Signature HMAC header, rejects expired or tampered deliveries, and returns the normalized ReceivedEmail. Signature verification mechanics live on Webhook Events Overview; Node-specific manual verification is on Webhook Signature Verification.
Sending, replying, and forwarding#
Three operations cover all outbound mail: send for a new message, reply to continue a thread from a ReceivedEmail, and forward to pass an inbound message to a new recipient.
The differences that matter:
send: a brand-new email. You controlfrom,to,subject, and body fully.reply: continues a thread from aReceivedEmail. Recipients, theRe:subject, and threading headers (In-Reply-To,References) are derived server-side from the inbound row. You cannot overridesubjecton reply, Gmail's Conversation View needs both a References match and a normalized-subject match to thread, so a custom subject silently breaks threading for part of the recipient population. Callsendif you need full subject control.forward: sends the inbound message content to a new recipient. Forward has no wait option; it always returns as soon as Primitive accepts the message.
const result = await client.send({
from: "Support <support@example.com>",
to: "alice@example.com",
subject: "Hello",
bodyText: "Hi there",
wait: true,
waitTimeoutMs: 5000,
});
console.log(result.id, result.status, result.queueId, result.deliveryStatus);
await client.reply(email, {
text: "Thanks for your email.",
from: "notifications@outbound.example.com",
});
await client.forward(email, {
to: "ops@example.com",
bodyText: "Can you take this one?",
});
result = client.send(
from_email="Support <support@example.com>",
to="alice@example.com",
subject="Hello",
body_text="Hi there",
idempotency_key="customer-key-abc123",
wait=True,
wait_timeout_ms=5000,
)
print(result.id, result.status, result.queue_id, result.delivery_status)
client.reply(
email,
"Thanks for your email.",
from_email="notifications@outbound.example.com",
)
client.forward(
email,
to="ops@example.com",
body_text="Can you take this one?",
)
wait := true
result, err := client.Send(ctx, primitive.SendParams{
From: "Support <support@example.com>",
To: "alice@example.com",
Subject: "Hello",
BodyText: "Hi there",
IdempotencyKey: "customer-key-abc123",
Wait: &wait,
WaitTimeoutMs: 5000,
})
_, err = client.Reply(ctx, email, primitive.ReplyParams{
BodyText: "Thanks for your email.",
From: "notifications@outbound.example.com",
})
_, err = client.Forward(ctx, email, primitive.ForwardParams{
To: "ops@example.com",
BodyText: "Can you take this one?",
})
Every reply defaults the From address to the inbound recipient (the address that received the email). Pass from (Node/Go) or from_email (Python) explicitly when your verified outbound domain differs from your inbound domain.
If the inbound row isn't in a state that can be replied to (rejected at ingestion, content discarded, or no recipient recorded), the API returns inbound_not_repliable (HTTP 422) and the SDK raises or returns an error. A missing Message-Id doesn't block the reply; it only omits the threading headers.
For full attachment handling (inline vs. Primitive Payloads by reference), see Sending, Replying, and Forwarding Email.
Wait mode and delivery status#
Wait mode is the behavior triggered by passing wait: true (Node/Python) or Wait pointer true (Go) to send/reply. By default, send, reply, and forward return as soon as Primitive accepts the message for delivery, fast, but you don't yet know whether the receiving mail server actually took it.
Set wait mode when you need to know the outcome before responding:
| Language | Flag | Timeout field | Default timeout |
|---|---|---|---|
| Node.js | wait: true | waitTimeoutMs | 30000 |
| Python | wait=True | wait_timeout_ms | 30000 |
| Go | Wait: &wait | WaitTimeoutMs | 30000 |
In wait mode, the call holds the HTTP response open until the first downstream SMTP delivery outcome, or until the timeout elapses. The wait timeout must be between 1000 and 30000 milliseconds; the SDKs reject anything outside that range before sending the request. Configure your runtime or HTTP client timeout to be longer than the wait timeout: the SDK READMEs recommend a request timeout "long enough for SMTP delivery, typically 30 to 60 seconds," or your own request times out before Primitive's response arrives.
Delivery status values#
Delivery status is the terminal outcome reported when wait mode is used. There are exactly four values, identical across every SDK:
| Status | Meaning |
|---|---|
delivered | Accepted by the receiving MTA. |
bounced | Rejected by the receiving MTA (the HTTP response is still 200 OK). |
deferred | Temporary failure (receiver returned 4xx); Primitive retries the delivery later. |
wait_timeout | No outcome was observed before the timeout. Treat this as "outcome unknown", the send may still complete after your response returns. |
Read it off the result object:
console.log(result.deliveryStatus); // "delivered" | "bounced" | "deferred" | "wait_timeout" | undefined
print(result.delivery_status) # "delivered" | "bounced" | "deferred" | "wait_timeout" | None
result.DeliveryStatus // primitiveapi.OptDeliveryStatus
wait_timeout is not a failure, it means the SMTP transaction hadn't resolved before your wait window closed. Don't retry the send on wait_timeout; the original attempt may still complete.
Idempotent retries#
Pass an idempotency key on send (and on Go's Forward, which calls Send internally) to make retries safe: reusing the same key returns the original response from the first send instead of creating a duplicate. See Request Options and Idempotency for the Node.js per-call mechanics.
Next steps#
Full attachment handling, threading control, and per-call request options in the Node.js SDK.
Receiving Inbound EmailEvery field on the ReceivedEmail shape, explained in depth for Node.js.
Webhook Events OverviewThe signature verification contract and event catalog shared by every SDK.
Verifying Inbound Email AuthenticityAnchor trust decisions to a sending domain using SPF/DKIM/DMARC results.
Was this page helpful?