Repository navigation
docs(workflows): send content from your code on the transactional email API, Node.js and Python pages #21819
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+168
−17
Merged
Changes from all commits
Commits
Show all changes
27 commits
Select commit
Hold shift + click to select a range
d15e134
docs(workflows): add the transactional email API page
mayteio 810cbed
docs(workflows): document the single send route, email status, and th…
mayteio 80fa5f8
docs(workflows): fix vale findings on the transactional email page
mayteio ded4a9a
docs(workflows): address SDK review on the transactional email page
mayteio 3fed7e4
docs(workflows): split transactional emails into overview and platfor…
mayteio b087284
docs(workflows): add a quick start and the Transactional section to t…
mayteio d55c648
docs(workflows): mention the per-send max_retries in Python
mayteio 38f138c
docs(workflows): send template_id and distinct_id at the top of the body
mayteio 76ad37a
docs(workflows): send content from your code on the transactional ema…
Silthus 831841b
docs(workflows): list every reserved header and the header and tag li…
Silthus b42086e
docs(workflows): say that a header name has no spaces
Silthus acd4a4b
docs(workflows): say that a header name has visible ASCII characters …
Silthus 2502eb6
docs(workflows): say what makes a code send the same request, and wha…
Silthus 673346e
docs(workflows): list capture_engagement on the template send, and sp…
Silthus 2885c5b
Merge branch 'master' into docs/transactional-email-api-code-form
Silthus 3f33a25
docs(workflows): cut cc, bcc, headers, tags and attachments from the …
Silthus 7295a66
docs(workflows): match the code form to the first send release
Silthus 2610e8d
docs(workflows): name the first release's field errors and the sender…
Silthus 5cffbd0
docs(workflows): link transactional emails to your users with the ide…
Silthus 51f427b
docs(workflows): say which signup email can leave out the distinct ID
Silthus c42c7c3
docs(workflows): match the link-emails section to the docs style rules
Silthus 01f7e5f
docs(workflows): fill the SDK examples with the reader's token and host
Silthus 86a0e43
docs(workflows): name the transactional_email scopes for sends and st…
Silthus 8d35205
docs(workflows): pick Transactional email in the key scopes list
Silthus 54968e2
docs(workflows): make a template transactional with the transactional…
Silthus bd17219
docs(workflows): say to retry a status read right after a send
Silthus 054a8a4
docs(workflows): Python code sends start at 7.84.0
Silthus File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -34,7 +34,7 @@ Send a JSON body with these fields: | |
| | `template_id` | Yes | The ID of the transactional template to send. | | ||
| | `to` | Yes | The email address of the recipient. One address for each request. | | ||
| | `message_data` | No | An 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_id` | No | The distinct ID of the recipient's person. PostHog records the send and its [engagement events](/docs/workflows/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. | | ||
| | `distinct_id` | No | The distinct ID of the recipient's person. PostHog records the send and its [engagement events](/docs/workflows/engagement-events) against this person. Pass the distinct ID that you pass to `identify()`. See [link emails to your users](#link-emails-to-your-users). | | ||
|
|
||
| In the template, use the same names that you send in `message_data`: | ||
|
|
||
|
|
@@ -74,7 +74,64 @@ curl -X POST "https://us.posthog.com/api/projects/@current/messaging/send/email/ | |
|
|
||
| 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](/docs/workflows/transactional-emails#errors). After a `503`, PostHog might have queued the email. Retry with the same `Idempotency-Key` rather than sending the email through another provider, or the recipient can get it twice. | ||
| When a send fails, the response carries a `code`. See [errors](/docs/workflows/transactional-emails#errors) and the [checks for content from your code](#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. | ||
|
|
||
| ## Link emails to your users | ||
|
|
||
| Pass the distinct ID that you pass to `identify()`, usually the ID your app gives that user, as `distinct_id`. PostHog then records the send, and its [engagement events](/docs/workflows/engagement-events) such as sent, opened, and clicked, against that user's person, next to their product events. | ||
|
|
||
| - **If you leave it out,** PostHog records the email against a person whose distinct ID is the recipient's email address. Unless your app identifies users by their email address, that's a separate person from your user. | ||
| - **For someone who isn't a user yet,** such as an invite or an email address check before the account exists, there's no distinct ID to pass. Leave `distinct_id` out. | ||
|
|
||
| ## 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](https://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](#send-an-email). | ||
|
|
||
| | Field | Required | Description | | ||
| | --- | --- | --- | | ||
| | `template_key` | Yes | A 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. | | ||
| | `to` | Yes | The email address of the recipient. | | ||
| | `distinct_id` | No | The distinct ID of the recipient's person, as for a template send. See [link emails to your users](#link-emails-to-your-users). | | ||
| | `subject` | Yes | The subject of the email. | | ||
| | `html` | One of `html` and `text` | The HTML body. PostHog sends it as you wrote it and renders no placeholders. | | ||
| | `text` | One of `html` and `text` | The plain-text body. If you leave it out, PostHog makes it from `html`. | | ||
| | `from` | No | The sender, as `[email protected]` or `Hedgebox <[email protected]>`, on a domain that your project verified. If you leave it out, PostHog uses the project's default sender. | | ||
| | `reply_to` | No | A list of up to 10 addresses that replies go to, without a display name. | | ||
| | `capture_engagement` | No | Whether PostHog captures the email's [engagement events](/docs/workflows/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](/docs/workflows/opt-outs#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](/docs/workflows/transactional-emails#retry-safely-with-an-idempotency-key) 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`. | ||
|
|
||
| ```bash | ||
| 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": "[email protected]", | ||
| "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 <[email protected]>" | ||
| }' | ||
| ``` | ||
|
|
||
| ### Checks for content from your code | ||
|
|
||
| These checks apply to content from your code, in addition to the [errors](/docs/workflows/transactional-emails#errors) of every send. PostHog sends nothing when a check fails. | ||
|
|
||
| | Status | Code | Cause | What to do | | ||
| | --- | --- | --- | --- | | ||
| | `400` | A code for the field, such as `required` or `invalid` | A 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. | | ||
| | `400` | `sender_not_verified` | No 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](/docs/workflows/configure-channels) and leave out `from`. | | ||
|
|
||
| ## Check an email's status | ||
|
|
||
|
|
@@ -84,7 +141,7 @@ To find out what happened to an email, read its status with the `message_id` fro | |
| 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. | ||
| Send the same `Authorization` header as for a send. The key needs the `transactional_email:read` or `transactional_email:write` scope. A key with a `hog_flow` scope still works. Only a project secret API key can read a status. | ||
|
|
||
| A found email returns `200`: | ||
|
|
||
|
|
@@ -100,7 +157,7 @@ A found email returns `200`: | |
|
|
||
| See [statuses and reasons](/docs/workflows/transactional-emails#check-an-emails-status) 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](/docs/workflows/transactional-emails#when-a-status-isnt-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. | ||
| A `404` with the code `not_found` right after a send means the status isn't there yet: a new send can take a few seconds to get its first status, so retry after a short wait. Later, a `404` means the email is older than 30 days or the ID is wrong. See [when a status isn't found](/docs/workflows/transactional-emails#when-a-status-isnt-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. | ||
|
|
||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,15 +19,15 @@ Reuse the PostHog client your application already creates, and set `secretKey` t | |
| ```js | ||
| import { PostHog } from "posthog-node" | ||
|
|
||
| const posthog = new PostHog("<ph_project_api_key>", { | ||
| host: "https://us.i.posthog.com", | ||
| const posthog = new PostHog("<ph_project_token>", { | ||
| host: "<ph_client_api_host>", | ||
| secretKey: process.env.POSTHOG_PROJECT_SECRET_API_KEY, | ||
| // This client only sends email, so it doesn't poll flag definitions with the secret key | ||
| enableLocalEvaluation: false, | ||
| }) | ||
| ``` | ||
|
|
||
| - **Local evaluation:** The client starts polling flag definitions with the secret key when it starts. A key with only the **Workflow** scope can't do that, so a client that only sends email needs `enableLocalEvaluation: false`. If your application [evaluates flags locally](/docs/feature-flags/local-evaluation) with the same client, give the key the **Feature flag** read scope too, and leave local evaluation on. | ||
| - **Local evaluation:** The client starts polling flag definitions with the secret key when it starts. A key with only the **Transactional email** scope can't do that, so a client that only sends email needs `enableLocalEvaluation: false`. If your application [evaluates flags locally](/docs/feature-flags/local-evaluation) with the same client, give the key the **Feature flag** read scope too, and leave local evaluation on. | ||
| - **Host:** On PostHog Cloud, the client sends email to the app host of your region, even when `host` is an ingestion host such as `us.i.posthog.com`. For any other host, such as a reverse proxy or a self-hosted instance, set `appHost` to your PostHog app URL. The client refuses to send the secret key to a host that might be a proxy. | ||
|
|
||
| ## Send an email | ||
|
|
@@ -58,6 +58,49 @@ Each request times out after 15 seconds. The client retries network errors, rate | |
|
|
||
| To change this, set `emailRequestTimeoutMs` and `emailRequestMaxRetries` on the client, or `maxRetries` on one send. When a job queue already retries the send, set the retries to `0` and pass an `idempotencyKey` that identifies the email. | ||
|
|
||
| ## Link emails to your users | ||
|
|
||
| Pass the distinct ID that you pass to `identify()`, usually the ID your app gives that user, as `distinctId`. PostHog then records the send, and its [engagement events](/docs/workflows/engagement-events) such as sent, opened, and clicked, against that user's person, next to their product events. | ||
|
|
||
| - **If you leave it out,** PostHog records the email against a person whose distinct ID is the recipient's email address. Unless your app identifies users by their email address, that's a separate person from your user. | ||
| - **For someone who isn't a user yet,** such as an invite or an email address check before the account exists, there's no distinct ID to pass. Leave `distinctId` out. | ||
|
|
||
| ## Send content from your code | ||
|
|
||
| From version 5.62.0, `send` also takes content that your code rendered, without a template in PostHog. Name the email with `templateKey`, and pass `subject` and `html`, `text`, or both. PostHog sends the content as you wrote it. | ||
|
|
||
| For a [React Email](https://react.email) component, render it to HTML with `render` from `@react-email/render`, and pass the result as `html`: | ||
|
|
||
| ```jsx | ||
| import { render } from "@react-email/render" | ||
| import { PasswordResetEmail } from "./emails/password-reset" | ||
|
|
||
| export async function sendPasswordReset({ userId, email, firstName, resetLink, resetRequestId }) { | ||
| const html = await render(<PasswordResetEmail firstName={firstName} resetLink={resetLink} />) | ||
| const { data, error } = await posthog.emails.send({ | ||
| to: email, | ||
| templateKey: "password-reset", | ||
| subject: "Reset your password", | ||
| html, | ||
| from: "Hedgebox <[email protected]>", | ||
| distinctId: userId, | ||
| idempotencyKey: `password-reset-${resetRequestId}`, | ||
| }) | ||
| if (error) { | ||
| throw error | ||
| } | ||
| return data.id | ||
| } | ||
| ``` | ||
|
|
||
| - **Key:** `templateKey` names the email, such as `password-reset`. PostHog groups the email's metrics and sends under it, so use the same key every time you send this email. | ||
| - **Text:** If you leave out `text`, PostHog makes it from `html`. | ||
| - **Sender:** `from` needs an address on a domain that your project verified. Without it, PostHog uses the project's default sender. `replyTo` takes one address or a list. | ||
| - **Engagement:** PostHog captures the email's engagement events unless you pass `captureEngagement: false`. | ||
| - **One form per send:** A send has `templateId` or `templateKey`, not both. Content from your code takes no `templateData`, because PostHog renders nothing. | ||
|
|
||
| See [content from your code](/docs/workflows/transactional-emails/api#send-content-from-your-code) for each field and its checks. | ||
|
|
||
| ## Check an email's status | ||
|
|
||
| ```js | ||
|
|
||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2 · Keep the Workflow write scope for making templates transactional
A personal API key with only
transactional_email:writereceives403frommake_transactional.MessageTemplatesViewSetstill declares this action underscope_object = "hog_flow", so it requireshog_flow:write; the new email scope authorizes sending and status reads, not template conversion. Keephog_flow:writein this instruction.