Send transactional email, such as a password reset or a receipt, and read its status.
An email goes out in one of two forms:
- A template made in PostHog, sent by its ID with
:template_idand:template_data. - Content your code rendered, named by
:template_key, with:subjectand:html,:textor both. To send a React email, render it to HTML in your app, for example with@react-email/render, and pass the result as:html. The SDK adds no rendering.
Setup
In PostHog, open the email template under Workflows, make it transactional, and copy its ID.
:template_idis that UUID. The code form needs no template.Create a project secret API key (
phs_...) with the "Send transactional email" preset (transactional_email:write) in your project settings. Addtransactional_email:readtoo if you read email statuses with a key that can't send. Keys made withhog_flow:writestill work. The project API key (phc_...) captures events but cannot send email.Set it as
:secret_keyin your PostHog config:config :posthog, api_host: "https://eu.i.posthog.com", api_key: "phc_...", secret_key: "phs_..."
Examples
Send a template made in PostHog:
{:ok, %PostHog.Emails.SendResult{id: message_id}} =
PostHog.Emails.send(
to: "user@example.com",
template_id: "0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b",
template_data: %{name: "Ada"},
distinct_id: "user_123",
idempotency_key: "welcome:user_123"
)Send content your code rendered:
PostHog.Emails.send(
to: "user@example.com",
template_key: "password-reset",
subject: "Reset your password",
html: rendered_html,
distinct_id: "user_123",
idempotency_key: "password-reset:#{reset.id}"
)Read what happened to it:
{:ok, %PostHog.Emails.Status{status: status}} = PostHog.Emails.get(message_id)Always pass :distinct_id. Without it, the email's events go to a person whose distinct ID
is the email address, not to your user. The SDK never takes it from PostHog.set_context/2.
What a result means
{:ok, result} means PostHog queued the email. Delivery happens later: read it with get/2,
or in the template's workflow in PostHog.
Where requests go
A client on PostHog Cloud sends to its region's app host, https://us.posthog.com or
https://eu.posthog.com. For any other :api_host, such as a reverse proxy, set :app_host
in the config, because email requests must not send the secret key to the proxy. Without it,
a send returns the error missing_app_host.
:secret_key also turns on local feature flag evaluation, which fetches flag definitions
from :api_host with that key. When :api_host is a proxy that must not see the key, set
enable_local_evaluation: false as well.
Timeouts, retries and idempotency
Each request times out after :email_request_timeout_ms (15 seconds by default). A send
retries network errors, rate limits, an idempotency key still in flight and an unavailable
service :email_request_max_retries times (2 by default), waiting about 1 and then 2 seconds,
or the server's Retry-After when it is longer. A Retry-After over 30 seconds fails at once
with :retry_after_ms set. A monthly quota is never retried.
Every attempt sends the same idempotency key, so a retry never sends twice. Without
:idempotency_key, the SDK makes a UUID for the call. When a job runner such as Oban
already retries the send, pass max_retries: 0 and an :idempotency_key that names the
email, such as "welcome:user_123". A generated key doesn't survive a job retry.
Sends and status reads block the calling process until they finish. The SDK keeps no email queue.
Errors
Every failure returns {:error, %PostHog.Emails.Error{}}. Branch on :code. The ones a
setup usually hits:
missing_secret_key: the config has no:secret_key.missing_app_host: the:api_hostis not PostHog Cloud. Set:app_host.authentication_failed(401) orpermission_denied(403): the key is wrong, or it lackstransactional_email:write.not_found(404): the template ID is wrong, or transactional email is not enabled for the project.client_disabled: the PostHog instance is not running or has no:api_key, so it sends nothing.client_closed: the PostHog instance stopped while a call waited to retry, so the retry was not sent.
An option the SDK refuses returns an error with an invalid_* code, such as
invalid_template_id, and the name :validation_error, before any request.
Testing
Requests go through the configured :api_client_module, so a Mox mock of
PostHog.API.Client sees every email request without network calls.
Summary
Types
@type send_option() :: {:to, String.t()} | {:distinct_id, String.t() | nil} | {:idempotency_key, String.t() | nil} | {:max_retries, non_neg_integer() | nil} | {:template_id, String.t()} | {:template_data, map() | nil} | {:template_key, String.t()} | {:subject, String.t()} | {:html, String.t() | nil} | {:text, String.t() | nil} | {:from, String.t() | nil} | {:reply_to, String.t() | [String.t()] | nil} | {:capture_engagement, boolean() | nil}
Options for send/2. Pass :template_id or :template_key, never both.
Every send takes:
:to- one recipient email address. Required.:distinct_id- the recipient's distinct ID in your product. The email's events go to this person.:idempotency_key- 1 to 255 visible ASCII characters. A send with a key PostHog already accepted queues nothing and returns the first send's result. Template sends and code sends share one set of keys: a key already used for another email returnsidempotency_key_reused.:max_retries- overrides the config's:email_request_max_retriesfor this send.0turns retries off.
A template made in PostHog takes:
:template_id- the UUID of a template made transactional in PostHog.:template_data- a map of values for the template's{{ variables.* }}placeholders.
Content your code rendered takes:
:template_key- names the email, such as"password-reset", so its metrics, logs and status group under it in PostHog. Letters, digits,-and_, at most 400.:subject- the subject line. Required.:htmland:text- the bodies. Give one or both. Without:text, PostHog derives it from:html.:from- a verified sender, such as"Acme <security@example.com>". Without it, the project's default sender.:reply_to- one address or a list of addresses.:capture_engagement- whether PostHog captures the email's opens and clicks as events of the recipient. Defaults totrue. A project whose admin turned engagement capture off captures nothing, whatever the send asks.
An option set to nil counts as left out.
Functions
@spec get(PostHog.supervisor_name(), String.t()) :: {:ok, PostHog.Emails.Status.t()} | {:error, PostHog.Emails.Error.t()}
Reads the status of an email that send/2 queued, for 30 days after the send.
Returns {:ok, %PostHog.Emails.Status{}} or {:error, %PostHog.Emails.Error{}}. The error
code is not_found when no email of this project has the ID, when the email is older than 30
days, or for a few seconds after the send. A key with only transactional_email:read can read
statuses but not send. Network errors, rate limits and an unavailable service are retried.
Examples
{:ok, %PostHog.Emails.Status{status: "bounced", reason: reason}} =
PostHog.Emails.get(message_id)
@spec send(PostHog.supervisor_name(), [send_option()] | map()) :: {:ok, PostHog.Emails.SendResult.t()} | {:error, PostHog.Emails.Error.t()}
Queues one email, from a template made in PostHog or from content your code rendered.
Returns {:ok, %PostHog.Emails.SendResult{}} once PostHog queued the email, or
{:error, %PostHog.Emails.Error{}}. See send_option/0 for the options. name is the
PostHog instance to send through, PostHog by default.
Examples
PostHog.Emails.send(
to: "user@example.com",
template_id: "0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b",
distinct_id: "user_123"
)
PostHog.Emails.send(MyPostHog,
to: "user@example.com",
template_key: "receipt",
subject: "Your receipt",
html: html,
distinct_id: "user_123"
)