Receiving and Parsing Inbound Email
Turn a raw inbound webhook payload into a normalized ReceivedEmail object with sender, reply-target, and thread fields, using primitive.receive or normalize_received_email.
Every inbound email.received webhook arrives as a big, schema-validated JSON payload. You rarely want to work with that shape directly: you want the sender's address, the thread headers, and the body text. primitive.receive(...) gives you that normalized object, a ReceivedEmail dataclass, in one call, ready to hand to client.reply(...) or client.forward(...).
Use this page when you're writing the webhook handler itself. If you already have a ReceivedEmail and want to send a reply, see Replying and Forwarding. If you need to verify the webhook signature yourself instead of letting receive() do it, see Verifying Webhook Signatures.
Normalize an inbound webhook in one call#
- 1
Install the SDK and set your API key#
pip install primitivedotdev export PRIMITIVE_API_KEY=prim_testSee Install and Configure the Python SDK if you haven't set up a client yet.
- 2
Call primitive.receive with the raw body, headers, and webhook secret#
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}bodymust be the exact raw bytes of the request, before any JSON parsing, re-serializing the body (even just re-encoding identical JSON) can change whitespace and break signature verification.secretis your account's webhook signing secret (whsec_...), the same one used to verify thePrimitive-Signatureheader. - 3
Read the normalized fields off the returned ReceivedEmail#
email.sender.address # "alice@example.com" email.sender.name # "Alice" or None email.received_by # the address that received this email email.received_by_all # list of every address it was sent to email.reply_target.address # who a reply should go to (may differ from sender) email.reply_subject # "Re: <original subject>", idempotently prefixed email.forward_subject # "Fwd: <original subject>", idempotently prefixed email.subject email.text email.thread.message_id email.thread.references email.raw # the full validated EmailReceivedEvent, if you need it
Expected result: webhook_handler returns {"ok": True} after sending a reply, and your reply thread appears under the original message in the recipient's client, client.reply(email, ...) derives the Re: subject and threading headers server-side from email.id.
What receive() does under the hood#
primitive.receive(...) calls handle_webhook(...) (verify signature, then validate against the JSON schema) and pipes the result through normalize_received_email(...). If you already have a validated EmailReceivedEvent, skip straight to normalization:
from primitive import handle_webhook
from primitive.received_email import normalize_received_email
raw_body: bytes = b"..." # the exact request bytes
headers: dict[str, str] = {} # the request headers
event = handle_webhook(body=raw_body, headers=headers, secret="whsec_...")
email = normalize_received_email(event)
Webhook signature verification itself (the Primitive-Signature HMAC scheme, tolerance window, and error codes) is documented on Verifying Webhook Signatures.
How each field is derived#
Every ReceivedEmail field is either read straight off the EmailReceivedEvent or derived with an explicit fallback rule, listed below. Knowing the rules helps you predict edge cases.
Header address parsing rejects any From/Reply-To value longer than 998 UTF-8 bytes (the RFC 5322 line limit) and falls back rather than guessing.
| Field | Derived from |
|---|---|
sender | Strict-parsed From header. Falls back to the SMTP envelope sender (email.smtp.mail_from) if the header doesn't strict-parse to a single valid address. |
reply_target | The first email.parsed.reply_to entry, when its address passes the same addr-spec check. Otherwise falls back to sender. |
received_by | The first address in email.smtp.rcpt_to. |
received_by_all | The full email.smtp.rcpt_to list. |
reply_subject | subject with an idempotent Re: prefix (won't double-prefix a subject that already starts with Re:, case-insensitive). Bare Re: when the subject is empty. |
forward_subject | Same idempotent-prefix logic, with Fwd: . |
thread.message_id | email.headers.message_id, unmodified. |
thread.in_reply_to / thread.references | email.parsed.in_reply_to / email.parsed.references, defaulting to empty lists. |
raw | The full input EmailReceivedEvent, untouched, use it whenever you need a field this table doesn't cover. |
normalize_received_email raises ValueError if email.smtp.rcpt_to is empty, every valid email.received event has at least one recipient, so an empty list means the payload is malformed. This is a hard invariant, not a soft warning: it means something upstream constructed a bad event.
sender is parsed leniently for display purposes and falls back to the SMTP envelope sender when the header doesn't validate. That makes it convenient for showing "From:..." in a UI, but it is not a safe authorization anchor, a spoofed From header can still populate sender. Use Authenticating Senders with SPF, DKIM, and DMARC to decide whether an inbound email can be trusted before acting on it.
Address shape: ReceivedEmailAddress#
sender and reply_target are both ReceivedEmailAddress, a frozen dataclass with a lowercased address and an optional display name.
@dataclass(frozen=True)
class ReceivedEmailAddress:
address: str # lowercased
name: str | None = None
address is always lowercased. format_address(...) renders it back to a header-ready string ("Alice <alice@example.com>" or bare "alice@example.com" when there's no display name), which is what the SDK uses internally when building forwarded-message text.
Thread shape: ReceivedEmailThread#
email.thread is a ReceivedEmailThread dataclass carrying the parent Message-Id plus the In-Reply-To and References header lists.
@dataclass(frozen=True)
class ReceivedEmailThread:
message_id: str | None
in_reply_to: list[str]
references: list[str]
You rarely need to build threading headers yourself, client.reply(...) and client.forward(...) derive them server-side from the email's id. thread is here mainly for logging, deduplication keys, or building your own indexing scheme across a conversation.
Next steps#
Pass a ReceivedEmail straight into client.reply and client.forward with correct threading.
Authenticating Senders with SPF, DKIM, and DMARCDecide whether to trust sender before acting on the normalized email.
Verifying Webhook SignaturesVerify the Primitive-Signature HMAC or Standard Webhooks header yourself.
Handling Webhook EventsBranch on payment.* and interaction.x402.* events from the same endpoint.
Was this page helpful?