diff --git a/contents/docs/workflows/transactional-email-api.mdx b/contents/docs/workflows/transactional-email-api.mdx new file mode 100644 index 000000000000..1f8fcfd52c4f --- /dev/null +++ b/contents/docs/workflows/transactional-email-api.mdx @@ -0,0 +1,748 @@ +--- +title: Transactional email API +sidebar: Docs +showTitle: true +--- + +import CalloutBox from "components/Docs/CalloutBox"; + +The transactional email API sends one email to one person from your server code, such as a welcome email, a signup confirmation, or a password reset. You keep the email as an HTML file in your repository. PostHog stores it as a message template, sends it through Workflows, and records delivery and engagement on the recipient's person. + + + +The API is in beta, and PostHog enables it per project. To use it, [ask us in-app](https://app.posthog.com/#panel=support%3Afeedback%3A%3Alow%3Atrue) to enable it for your project. Until then, the upload route, the send routes, and the change request route answer `404` with the code [`not_found`](#not_found). Routes, fields, and messages can change while the API is in beta. + + + +## How it works + +1. You write the email as an HTML file with [Liquid](https://shopify.github.io/liquid/) placeholders, such as `{{ variables.first_name }}`. +2. You upload the file to PostHog under a key that you choose, such as `welcome`. The first upload creates a message template, marks it as managed by code, and makes it transactional with your project's default sender. +3. Your server sends the template by its key, with the recipient and the values for the placeholders. + +In Node.js, `posthog-node` does steps 2 and 3 for you. It reads the file on every send and uploads it when its content changed. In other languages, you call the [upload route](#upload-a-template-by-key) and the [send route](#send-an-email) over HTTP. + +The same key works in every project, so one codebase can send to a staging project and a production project. + +## Before you start + +1. **Verify a sender.** [Set up the email channel](/docs/workflows/configure-channels) with a domain that you own, and make the verified sender your project's default sender: click **Make default** next to the sender in the email channel settings. The first upload uses the default sender. Without one, the upload fails with [`sender_unavailable`](#sender_unavailable). +2. **Create a project secret API key.** Create a [project secret API key](/docs/api/project-secret-api-keys) (it starts with `phs_`) with the `hog_flow:write` scope. Keep it on your server, for example in the `POSTHOG_PROJECT_SECRET_API_KEY` environment variable. Never put it in browser or mobile code. +3. **Ask for access.** The API must be enabled for your project. See the beta note above. + +A project secret API key can only upload templates and send them. It can't read, edit, or delete templates. To call the other template routes, such as [adoption](#adopt-a-template-made-in-posthog), use a [personal API key](/docs/api/personal-api-keys) with `hog_flow:write`. A [change request](#run-with-posthog-code) needs `hog_flow:read` and `task:write` instead. + +Project secret API keys are in beta and may not be available in your organization yet. Until they are, the upload and send routes also accept a personal API key with `hog_flow:write` and editor access to Workflows. With a personal API key, put the project ID in the URL instead of `@current`. `posthog-node` always calls `@current`, so use it with a project secret API key. + +## Write a template + +A template is an HTML file. The `` is the subject line, and `<meta name="preheader">` is the inbox preview text. Both can use Liquid. + +```html file=emails/welcome.html +<!doctype html> +<html> + <head> + <meta charset="utf-8" /> + <title>Welcome to Hedgebox, {{ variables.first_name }} + + + +

Hi {{ variables.first_name }},

+

Your account is ready. Log in to get started.

+ + +``` + +- **Values come from the send only.** Each send passes its values as `data` (in Node.js) or `message_data` (over HTTP). The template reads them as `{{ variables. }}`. +- **A send never reads person properties.** PostHog attaches the person to the send for attribution, but the template can't read `{{ person.properties.* }}`. Pass every value the email needs in the send. +- **The key names the template.** A key has only letters, digits, hyphens (`-`), and underscores (`_`), with at most 400 characters. In Node.js, the key is the file name without `.html`, so `emails/welcome.html` has the key `welcome`. + +## Send from Node.js + +Use `posthog-node` on your server. Set `secretKey` to your project secret API key: + +```js +import { PostHog } from 'posthog-node' + +const posthog = new PostHog('', { + host: '', + secretKey: process.env.POSTHOG_PROJECT_SECRET_API_KEY, + // Leave this out if the key also has feature_flag:read and you use local evaluation + enableLocalEvaluation: false, +}) +``` + +`secretKey` also turns on [local evaluation](/docs/feature-flags/local-evaluation) of Feature Flags, which needs the `feature_flag:read` scope. If the key only has `hog_flow:write`, set `enableLocalEvaluation: false`. + +Reference the template file once, then send it: + +```js +const welcome = posthog.emails.templateFile(new URL('./emails/welcome.html', import.meta.url)) + +const { data, error } = await posthog.emails.send({ + template: welcome, + to: 'jane@example.com', + data: { first_name: 'Jane', login_url: 'https://hedgebox.example.com/login' }, + distinctId: 'user_42', +}) + +if (error) { + console.error(error.code, error.message, error.docsUrl) +} +``` + +- `templateFile()` takes a `file:` URL or an absolute path to an `.html` file. It throws a `TypeError` for anything else, so call it once when your module loads. Options: `key` (defaults to the file name without `.html`) and `name` (the name in the template library, defaults to the subject). +- `to` is the recipient's email address. +- `data` fills the `{{ variables.* }}` placeholders. +- `distinctId` is optional. It attributes the send and its [engagement events](/docs/workflows/engagement-events) to that person. Without it, PostHog uses the recipient's email address as the distinct ID. +- `send()` never throws on API or network errors. It returns `{ data, error }`. On success, `data` is `{ id, warnings }`. On failure, `error` is `{ code, message, statusCode, docsUrl, field }`. Branch on `error.code`. Each code has a section in [errors](#errors). + +To send a template that you upload some other way, pass its key as a string: `template: 'welcome'`. A key string sends no content hash, so the send always uses the published content. + +### How the file stays in sync + +On every send with a template file, `posthog-node` reads the file, computes its [content hash](#content-hash), and sends the key and the hash: + +- **PostHog holds this content.** The email goes out. This is one request. +- **PostHog never had this content**, because the file is new or you edited it. PostHog answers [`template_not_found`](#template_not_found) or [`template_outdated`](#template_outdated) and queues nothing. `posthog-node` uploads the file and sends again, once. This is three requests. +- **Newer content replaced this content.** The email goes out with the newer content, and `data.warnings` has a [`template_superseded`](#template_superseded) warning. `posthog-node` doesn't upload. It writes the warning to its logger once per key and content. + +So you don't need an upload step or a deploy step: a send from an edited file publishes the edit. + +### Rolling deploys + +During a rolling deploy, your old instances and your new instances run at the same time, each with its own copy of the file. An old instance can't bring back content from any of the last 100 publishes: + +- PostHog remembers the last 100 content hashes it published. An older hash counts as never published, so a file older than that uploads again. +- When a send carries one of the remembered hashes, PostHog sends the current content and adds the [`template_superseded`](#template_superseded) warning. Nothing is uploaded or reverted. +- The automatic upload after `template_outdated` sets `if_not_superseded`, so it never publishes content that one of the last 100 publishes replaced. See [automatic uploads](#automatic-uploads-and-if_not_superseded). + +Two side effects to know about: + +- In development, when you edit a file back to an earlier version, a send doesn't upload it, because PostHog published that content before. The warning tells you to upload it on purpose. +- When two people share one project with different local copies of a file, each sees the other's content as newer. Use one project per person, or upload explicitly. + +### Roll back on purpose + +To publish content that newer content replaced, such as when you roll back a release, upload the file explicitly: + +```js +const { data, error } = await posthog.emails.uploadTemplate(welcome, { + source: { repository: 'github.com/example/app', ref: process.env.GIT_COMMIT_SHA }, +}) +// data: { id, key, result: 'created' | 'updated' | 'unchanged', contentHash } +``` + +An explicit upload always publishes. `source` is optional and shows in PostHog next to the template. Each part that you leave out comes from [source detection](#where-the-source-details-come-from). + +### Ship template files with serverless bundles + +`posthog-node` reads the template file at runtime, so the file must be in the deployment. Bundlers only ship files that your code imports, so a serverless function can deploy without its HTML files. A send then fails with [`template_file_unreadable`](#template_file_unreadable). + +Tell your platform to include the files: + +- **Next.js:** add the files to `outputFileTracingIncludes` in `next.config.js`, for example `{ '/api/signup': ['./emails/**/*.html'] }`. +- **Vercel functions:** add `"includeFiles": "emails/**"` to the function in `vercel.json`. +- **Netlify functions:** add `included_files = ["emails/**"]` under `[functions]` in `netlify.toml`. + +When a bundler moves your code to another directory, `import.meta.url` no longer points next to the file. Build the path from the project root instead, for example `path.join(process.cwd(), 'emails/welcome.html')`. + +### Edge runtimes + +Edge runtimes have no file system, so the edge entry of `posthog-node` only takes key strings. `templateFile()` throws there. Upload the file from a build step or a Node.js script with `uploadTemplate()`, or over HTTP with the [upload route](#upload-a-template-by-key), then send by key. + +## Upload a template by key + +`PUT /api/projects/:project_id/messaging_templates/by_key/:key/` + +Use this route from any language, for example in a deploy step. With a project secret API key, use `@current` as the project ID. A personal API key or a session can call it too, with editor access to Workflows. + +```bash +curl -X PUT "/api/projects/@current/messaging_templates/by_key/welcome/" \ + -H "Authorization: Bearer $POSTHOG_PROJECT_SECRET_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Welcome", + "subject": "Welcome to Hedgebox, {{ variables.first_name }}", + "html": "

Hi {{ variables.first_name }}

", + "source": { "repository": "github.com/example/app", "path": "emails/welcome.html", "ref": "3f2c1ab" } + }' +``` + +| Field | Type | Description | +| --- | --- | --- | +| `name` | string, required | Name in the template library. At most 400 characters. | +| `subject` | string, required | Subject line. Supports Liquid. | +| `html` | string, required | HTML body, stored as you send it. Supports Liquid. | +| `text` | string | Plain-text body. Leave it out to remove the stored one. | +| `preheader` | string | Inbox preview text. Leave it out to remove the stored one. | +| `source` | object | `{ repository, path, ref }`, each a string of at most 400 characters. Shown in PostHog next to the template. Leave it out, or leave out a part, to keep what is stored. | +| `if_not_superseded` | boolean | Refuse to publish content that newer content already replaced. See [automatic uploads](#automatic-uploads-and-if_not_superseded). | + +PostHog stores `subject`, `preheader`, `html`, and `text` byte for byte. It doesn't trim whitespace, so the hash you compute matches the hash PostHog stores. + +The response: + +```json +{ + "id": "01a14631-40b7-0000-4dfd-60cd5a2e12ad", + "key": "welcome", + "result": "created", + "content_hash": "v1:67bd514eca757ac54081c4eda9ca9cc9d3704594f0d83843a76a77364a2c6a12", + "managed_by": "code", + "source": { "repository": "github.com/example/app", "path": "emails/welcome.html", "ref": "3f2c1ab" }, + "transactional_hog_flow_id": "01a14631-40c1-0000-d930-cf5400ef453a" +} +``` + +| `result` | Status | What happened | +| --- | --- | --- | +| `created` | `201` | No template had this key. PostHog created one, marked it as managed by code, and made it transactional with the project's default sender. | +| `updated` | `200` | PostHog published the uploaded content. The next send uses it. | +| `unchanged` | `200` | The template already had this name and content. PostHog wrote nothing, except source parts that changed. | +| `superseded` | `200` | Only with `if_not_superseded`. Newer content replaced this content, so PostHog wrote nothing. `content_hash` is the current hash. | + +An upload never turns a paused template back on. It keeps the template's sender, unless the template's workflow was deleted: then the upload creates a new workflow with the project's default sender. When two first uploads of one key run at the same time, both succeed and one template exists. + +### Automatic uploads and `if_not_superseded` + +An automatic upload, like the one `posthog-node` makes after `template_outdated`, should set `"if_not_superseded": true`. Between the `409` and the upload, another instance can publish the same content and a newer deploy can replace it. With the option, the upload answers `superseded` and writes nothing, so the old content doesn't come back. Send again, and the email goes out with the current content and a [`template_superseded`](#template_superseded) warning. + +Leave the option out for an upload that you make on purpose, such as a [rollback](#roll-back-on-purpose). + +## Send an email + +`POST /api/projects/:project_id/messaging/send/email/` + +The send route names the template in the body, by key or by ID: + +```bash +curl -X POST "/api/projects/@current/messaging/send/email/" \ + -H "Authorization: Bearer $POSTHOG_PROJECT_SECRET_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "template": { "key": "welcome" }, + "to": "jane@example.com", + "identifiers": { "distinct_id": "user_42" }, + "message_data": { "first_name": "Jane", "login_url": "https://hedgebox.example.com/login" } + }' +``` + +| Field | Type | Description | +| --- | --- | --- | +| `template` | object, required | Exactly one of `{ "key": "welcome" }`, `{ "key": "welcome", "hash": "v1:..." }`, or `{ "id": "