Send transactional emails with the API

Contents

Use the API to send transactional emails from any language. For Node.js and Python, the Node.js SDK and the Python SDK handle retries and idempotency keys for you.

Before you start, create a transactional email and create a project secret API key.

Send an email

POST https://us.posthog.com/api/projects/@current/messaging/send/email/
  • Host: Use https://us.posthog.com or https://eu.posthog.com, the host where you log in to PostHog. Don't use an ingestion host like us.i.posthog.com. If you self-host PostHog, use the URL of your instance.
  • Project: @current is the project that the key belongs to. You can use your project ID instead.

Request

Send these headers:

HeaderValue
AuthorizationBearer followed by your project secret API key
Content-Typeapplication/json
Idempotency-KeyOptional. A unique key for this email. See retry safely.

Send a JSON body with these fields:

FieldRequiredDescription
template_idYesThe ID of the transactional template to send.
toYesThe email address of the recipient. One address for each request.
message_dataNoAn object with the values for the template's {{ variables.* }} placeholders. For example, {"reset_link": "https://example.com/reset"} fills {{ variables.reset_link }}. The template can't read person properties, so pass all the data that the email needs here.
distinct_idNoThe distinct ID of the recipient's person. PostHog records the send and its engagement events against this person. If you leave it out, PostHog uses the email address as the distinct ID, which might not match the person in your product. We recommend that you always send it.

In the template, use the same names that you send in message_data:

liquid
Hi {{ variables.first_name }}, use this link to reset your password: {{ variables.reset_link }}

Response

A successful request returns 202 Accepted:

JSON
{
"message_id": "01963d8a-6f2c-7d4e-9b1a-3c5e8f2a7b10",
"replayed": false
}

A 202 means that PostHog queued the email. It doesn't mean that the email was delivered. To see what happened to the email, check its status with its message_id, or open the template's Sends tab.

replayed is true when an earlier request with the same Idempotency-Key already queued this email. In that case, PostHog sent no new email, and message_id is the ID of the first send.

Example

Terminal
curl -X POST "https://us.posthog.com/api/projects/@current/messaging/send/email/" \
-H "Authorization: Bearer $POSTHOG_PROJECT_SECRET_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: password-reset-8f14e45f" \
-d '{
"template_id": "<template_id>",
"to": "jane@example.com",
"distinct_id": "user_123",
"message_data": { "first_name": "Jane", "reset_link": "https://example.com/reset?token=abc123" }
}'

The older route, POST /api/projects/@current/messaging_templates/:template_id/send/, still works. It takes the template ID in the URL and the distinct ID as identifiers.distinct_id.

When a send fails, the response carries a code. See errors and the checks for content from your code.

delivery_unavailable

A 503 with the code delivery_unavailable means that PostHog couldn't confirm that it queued the email. PostHog might still send it. Retry with the same Idempotency-Key rather than sending the email through another provider, or the recipient can get it twice. The SDKs retry this on their own.

Send content from your code

You can also send the subject and the body from your code, without a template in PostHog. Use this when your code renders the email. For a React Email component, render it to HTML with render from @react-email/render, and send the result as html. Name the email with template_key, and send the content in the body. Use the same POST route and headers as for a template send.

FieldRequiredDescription
template_keyYesA name for this email, such as password-reset. It has at most 400 letters, digits, hyphens (-), and underscores (_). PostHog groups the metrics and the sends of this email under the key, so use the same key every time you send this email.
toYesThe email address of the recipient.
distinct_idNoThe distinct ID of the recipient's person, as for a template send. We recommend that you always send it.
subjectYesThe subject of the email.
htmlOne of html and textThe HTML body. PostHog sends it as you wrote it and renders no placeholders.
textOne of html and textThe plain-text body. If you leave it out, PostHog makes it from html.
fromNoThe sender, as security@example.com or Hedgebox <security@example.com>, on a domain that your project verified. If you leave it out, PostHog uses the project's default sender.
reply_toNoA list of up to 10 addresses that replies go to, without a display name.
capture_engagementNoWhether PostHog captures the email's engagement events. The default is true. Send false to turn capture off for this email. If the project's Capture email engagement events setting is off, PostHog captures nothing, whatever you send.

A request has template_id or template_key, not both. Content from your code takes no message_data, because PostHog renders nothing. A template send takes none of subject, html, text, from, reply_to, and capture_engagement, because the template uses its own settings. PostHog returns a 400 that names the field in attr.

If the to address is on the suppression list, PostHog sends nothing and returns recipient_suppressed.

The response is the same 202 as for a template send. A retry with the same Idempotency-Key is the same request when every field in the table above has the same value as in the first request. PostHog also compares the sender that it chose: if you leave out from and the project's default sender changes before the retry, PostHog returns idempotency_key_reused.

Terminal
curl -X POST "https://us.posthog.com/api/projects/@current/messaging/send/email/" \
-H "Authorization: Bearer $POSTHOG_PROJECT_SECRET_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: password-reset-8f14e45f" \
-d '{
"template_key": "password-reset",
"to": "jane@example.com",
"distinct_id": "user_123",
"subject": "Reset your password",
"html": "<p>Hi Jane,</p><p><a href=\"https://example.com/reset?token=abc123\">Reset your password</a>.</p>",
"from": "Hedgebox <security@example.com>"
}'

Checks for content from your code

These checks apply to content from your code, in addition to the errors of every send. PostHog sends nothing when a check fails.

StatusCodeCauseWhat to do
400A code for the field, such as required or invalidA field is missing or breaks a rule above, or PostHog doesn't take the field. The type is validation_error, and attr names the field, such as subject.Fix the field. If the field comes from a newer SDK, update PostHog or leave the field out.
400sender_not_verifiedNo verified sender of the project can send from the from address. Or the request has no from, and the project has no verified default sender.Send from an address on a verified domain, or set a default sender and leave out from.

Check an email's status

To find out what happened to an email, read its status with the message_id from the send:

GET https://us.posthog.com/api/projects/@current/messaging/emails/:message_id/

Send the same Authorization header as for a send. The key needs the hog_flow:read or hog_flow:write scope. Only a project secret API key can read a status.

A found email returns 200:

JSON
{
"id": "01963d8a-6f2c-7d4e-9b1a-3c5e8f2a7b10",
"status": "delivered",
"created_at": "2026-10-28T16:27:44.364000Z",
"status_at": "2026-10-28T16:27:49.625000Z",
"reason": null
}

See statuses and reasons for what each value means.

A 404 with the code not_found means the status isn't there yet, is older than 30 days, or the ID is wrong. See when a status isn't found. It returns 401 when the request has no project secret API key, 403 when the key doesn't have a Workflow scope, and 429 when you read too often.

Each project secret API key can read 600 statuses per minute and 20,000 per hour. All the keys of one project share the same limits in total. Reads have their own limits, so they don't use up the limits for sends.

Example

Terminal
curl "https://us.posthog.com/api/projects/@current/messaging/emails/<message_id>/" \
-H "Authorization: Bearer $POSTHOG_PROJECT_SECRET_API_KEY"

Still have questions?

Was this page useful?