Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
d15e134
docs(workflows): add the transactional email API page
mayteio Oct 24, 2026
810cbed
docs(workflows): document the single send route, email status, and th…
mayteio Oct 29, 2026
80fa5f8
docs(workflows): fix vale findings on the transactional email page
mayteio Oct 29, 2026
ded4a9a
docs(workflows): address SDK review on the transactional email page
mayteio Oct 29, 2026
3fed7e4
docs(workflows): split transactional emails into overview and platfor…
mayteio Oct 29, 2026
b087284
docs(workflows): add a quick start and the Transactional section to t…
mayteio Oct 29, 2026
d55c648
docs(workflows): mention the per-send max_retries in Python
mayteio Oct 29, 2026
38f138c
docs(workflows): send template_id and distinct_id at the top of the body
mayteio Oct 30, 2026
76ad37a
docs(workflows): send content from your code on the transactional ema…
Silthus Oct 31, 2026
831841b
docs(workflows): list every reserved header and the header and tag li…
Silthus Nov 1, 2026
b42086e
docs(workflows): say that a header name has no spaces
Silthus Nov 1, 2026
acd4a4b
docs(workflows): say that a header name has visible ASCII characters …
Silthus Nov 1, 2026
2502eb6
docs(workflows): say what makes a code send the same request, and wha…
Silthus Nov 1, 2026
673346e
docs(workflows): list capture_engagement on the template send, and sp…
Silthus Nov 1, 2026
2885c5b
Merge branch 'master' into docs/transactional-email-api-code-form
Silthus Nov 2, 2026
3f33a25
docs(workflows): cut cc, bcc, headers, tags and attachments from the …
Silthus Nov 2, 2026
7295a66
docs(workflows): match the code form to the first send release
Silthus Nov 2, 2026
2610e8d
docs(workflows): name the first release's field errors and the sender…
Silthus Nov 2, 2026
5cffbd0
docs(workflows): link transactional emails to your users with the ide…
Silthus Nov 3, 2026
51f427b
docs(workflows): say which signup email can leave out the distinct ID
Silthus Nov 3, 2026
c42c7c3
docs(workflows): match the link-emails section to the docs style rules
Silthus Nov 3, 2026
3afd19a
docs(workflows): send transactional emails from Go, .NET, Rust, Ruby,…
Silthus Nov 4, 2026
8cfab85
docs(workflows): fix the SDK email snippets from the first review
Silthus Nov 4, 2026
ecf0f78
docs(workflows): fix the .NET import and status helpers from the seco…
Silthus Nov 4, 2026
01f7e5f
docs(workflows): fill the SDK examples with the reader's token and host
Silthus Nov 4, 2026
f057379
docs(workflows): show copies and attachments for Rust and Ruby
Silthus Nov 4, 2026
35245a6
Merge docs/transactional-email-api-code-form into docs/transactional-…
Silthus Nov 4, 2026
d00e3e8
docs(workflows): show copies and attachments for PHP
Silthus Nov 4, 2026
e1aae00
docs(workflows): show copies and attachments for .NET and drop the co…
Silthus Nov 4, 2026
86a0e43
docs(workflows): name the transactional_email scopes for sends and st…
Silthus Nov 5, 2026
8d35205
docs(workflows): pick Transactional email in the key scopes list
Silthus Nov 5, 2026
54968e2
docs(workflows): make a template transactional with the transactional…
Silthus Nov 5, 2026
919fb4b
Merge docs/transactional-email-api-code-form into docs/transactional-…
Silthus Nov 5, 2026
bd17219
docs(workflows): say to retry a status read right after a send
Silthus Nov 5, 2026
de5f2d1
docs(workflows): name .NET SDK 2.20.0 as the first version with email
Silthus Nov 5, 2026
054a8a4
docs(workflows): Python code sends start at 7.84.0
Silthus Nov 5, 2026
c4bf21e
Merge docs/transactional-email-api-code-form into docs/transactional-…
Silthus Nov 5, 2026
727ba84
docs(workflows): rewrite transactional email docs for a faster first …
Silthus Nov 5, 2026
b2a57a5
docs(workflows): keep job retries inside the idempotency key's 24 hours
Silthus Nov 5, 2026
2448c60
docs(workflows): add Node.js and Python attachments and fix the proxy…
Silthus Nov 5, 2026
216b96c
Merge remote-tracking branch 'origin/master' into docs/transactional-…
Silthus Nov 6, 2026
8196ea6
docs(workflows): hold SDK copies and attachments until the SDKs relea…
Silthus Nov 6, 2026
299002f
docs(workflows): send copies and attachments from every SDK
Silthus Nov 6, 2026
bac77ec
Merge remote-tracking branch 'origin/master' into docs/transactional-…
Silthus Nov 6, 2026
6d25658
Merge remote-tracking branch 'origin/master' into docs/transactional-…
Silthus Nov 9, 2026
f3785a7
docs(workflows): send email files by upload, then reference
Silthus Nov 9, 2026
8988752
docs(workflows): link the upload host from every SDK page
Silthus Nov 9, 2026
9bc700d
docs(workflows): fix the file checks and show refusals in the receipt…
Silthus Nov 9, 2026
0aa1457
docs(workflows): say the upload limit counts when PostHog gives the URL
Silthus Nov 9, 2026
c39f467
docs(workflows): offer a link for new files over the upload limit
Silthus Nov 9, 2026
7a46fb2
docs(workflows): list the upload route's 404 and count the bodies at …
Silthus Nov 9, 2026
cbb44fb
Merge remote-tracking branch 'origin/master' into docs/transactional-…
Silthus Nov 13, 2026
3e36d02
docs(workflows): name the SDK versions that send copies and attachments
Silthus Nov 13, 2026
cf97265
docs(workflows): install the Rust SDK release that sends attachments
Silthus Nov 13, 2026
1ab42fb
docs(workflows): name the Python and .NET versions that send attachments
Silthus Nov 13, 2026
b811a84
docs(workflows): send a calendar invite with content type parameters
Silthus Nov 13, 2026
d597bf7
docs(workflows): show the calendar invite's content type without a sc…
Silthus Nov 13, 2026
821ff4c
docs(workflows): use en dashes in the attachments bullets
Silthus Nov 13, 2026
0070f3e
Merge remote-tracking branch 'origin/master' into docs/transactional-…
Silthus Nov 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 145 additions & 11 deletions contents/docs/workflows/transactional-emails/api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ Each SDK page shows how to set up the client and handle errors.

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) and the [checks for content from your code](#checks-for-content-from-your-code).
When a send fails, the response carries a `code`. See [errors](/docs/workflows/transactional-emails#errors), the [checks for content from your code](#checks-for-content-from-your-code), and the [checks for files](#checks-for-files).

### `delivery_unavailable`

Expand Down Expand Up @@ -402,23 +402,157 @@ PostHog returns a `400` and sends nothing when:

### Copies and attachments

<CalloutBox icon="IconWarning" title="Attachments are temporarily unavailable" type="warning">

A send with `attachments` gets a `400` error with the code `invalid_request`, and `attr` is `attachments`. PostHog sends nothing. Leave out `attachments` to send the email. `cc` and `bcc` work as described below.

</CalloutBox>

Content from your code also takes these fields. A template send takes none of them, because the template uses its own settings.

| Field | Required | Description |
| --- | --- | --- |
| `cc` | No | A list of up to 10 addresses that get a copy, without a display name. |
| `bcc` | No | A list of up to 10 addresses that get a hidden copy, without a display name. The other recipients don't see them. |
| `attachments` | No | A list of up to 10 files, 512 KB in total after decoding. Each file is an object with `filename`, `content` in Base64, and optionally `content_type`, such as `application/pdf`, and `content_id`. |
| `attachments` | No | A list of up to 10 files that you [uploaded first](#send-files). Each file is an object with `sha256`, `filename`, and optionally `content_type`, such as `application/pdf`, and `content_id`. |

- **Size** – An email can have 10 files and 40 MB per message after Base64 encoding, about 29 MB of files. Base64 encoding makes each file about a third larger, and the email's HTML and text count too, also at their Base64 size. One file can use all of the limit.
- **Inline images** – Set `content_id` on a file, and use it in the HTML as `<img src="cid:logo">`.
- **Content type parameters** – `content_type` can have parameters after the type. For a calendar invite, add `method=REQUEST`, so that mail apps can show the file as an invitation, not as a plain file:

```json
{
"sha256": "1c54c1de7da52dae1aa7c8caea31c87272055e3f5308b8a81b456bce26c721ba",
"filename": "invite.ics",
"content_type": "text/calendar; charset=utf-8; method=REQUEST"
}
```

PostHog refuses a `name` or `filename` parameter, because the file name comes from `filename`.
- **Blocked files** – PostHog refuses file types that mailbox providers block, such as `.exe`, and a filename with a path.
- **Viruses** – PostHog's email provider scans each file. If it finds a virus, the email gets the status `failed` with the reason `provider_rejected`.
- **SDKs** – The SDKs hash, upload, and attach the files for you. Give each file's bytes to the SDK.
- **Older PostHog versions** – A PostHog version that doesn't take these fields yet refuses the send with a `400` that names the field. PostHog then sends nothing, and never sends the email without the field.

### Send files

The files of an email go to PostHog before the email. Your SDK does these steps for you. With the API, do them yourself:

1. **Hash each file.** Get the SHA-256 of the file's bytes, as 64 lowercase hex characters, and the size of the file in bytes.
2. **Ask where to upload.** Send the hash and the size of each file of the email to the [upload route](#ask-where-to-upload). PostHog answers `present` for a file that it already has, and `upload` with an upload URL for each other file.
3. **Upload the files.** Send the bytes of each `upload` file to its URL. Don't send your PostHog API key with the upload.
4. **Send the email.** When all uploads are successful, send the email. Put the `sha256` and the `filename` of each file in `attachments`.

Ask where to upload before each send, also for a file that you sent before. PostHog answers `present` for a file that it still has, so a file that you send to many recipients, such as your terms, goes up only one time.

#### Ask where to upload

```
POST https://us.posthog.com/api/projects/@current/messaging/attachments/uploads/
```

Send the `Authorization` and `Content-Type` headers of a [send](#request). The key needs the `transactional_email:write` scope. Send a JSON body with a `files` list of 1 to 10 files, all for one email:

| Field | Required | Description |
| --- | --- | --- |
| `sha256` | Yes | The SHA-256 of the file's bytes, as 64 lowercase hex characters. |
| `size` | Yes | The size of the file in bytes, at least 1. |

```bash
curl -X POST "https://us.posthog.com/api/projects/@current/messaging/attachments/uploads/" \
-H "Authorization: Bearer $POSTHOG_PROJECT_SECRET_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"files": [
{"sha256": "d00ca3c2296eace098658a8576dafec8a959f5da536a51c8f9d060c00e427252", "size": 48213},
{"sha256": "aba4cc7ef2a4423000b9ec5269993717c92b29f092eac5b791c7e9ce24a69306", "size": 120544}
]
}'
```

A successful request returns `200`, with one answer for each file, in the order of the request:

```json
{
"files": [
{
"sha256": "d00ca3c2296eace098658a8576dafec8a959f5da536a51c8f9d060c00e427252",
"status": "upload",
"url": "https://<bucket>.s3.us-east-1.amazonaws.com/<key>?X-Amz-Algorithm=AWS4-HMAC-SHA256&...",
"method": "PUT",
"headers": {
"Content-Length": "48213",
"x-amz-checksum-sha256": "0AyjwilurOCYZYqFdtr+yKlZ9dpTalHI+dBgwA5CclI="
},
"url_expires_at": "2026-11-10T09:15:00Z"
},
{
"sha256": "aba4cc7ef2a4423000b9ec5269993717c92b29f092eac5b791c7e9ce24a69306",
"status": "present",
"expires_at": "2026-12-16T09:00:00Z"
}
]
}
```

- **`present`** – PostHog has this file from an earlier upload by your project. Upload nothing. `expires_at` is the time when PostHog deletes the file, if no send uses it before then.
- **`upload`** – Send the file's bytes to `url`, with `method` and with the `headers` exactly as given, before `url_expires_at`. The URL is valid for 15 minutes. The bytes of each `upload` file count toward your project's daily upload limit when PostHog gives you the URL, even if you don't upload the file. Upload each file soon after you ask.

#### Upload a file

```bash
curl -X PUT "<url>" \
-H "Content-Length: 48213" \
-H "x-amz-checksum-sha256: 0AyjwilurOCYZYqFdtr+yKlZ9dpTalHI+dBgwA5CclI=" \
--data-binary @invoice-1042.pdf
```

A successful upload returns `200`. The upload fails with a `400` or a `403` when the bytes don't agree with `size` and `sha256`, when a header isn't as given, or when the URL expired. Ask where to upload again to get a new URL. Don't send the email until all uploads are successful.

#### Allow the upload host

The upload goes to Amazon S3, not to the PostHog host. The SDKs upload there too. If your network limits outgoing traffic, let your servers connect with HTTPS to the host of `url`:

| PostHog Cloud | Upload host |
| --- | --- |
| US | A host that ends in `.s3.us-east-1.amazonaws.com` |
| EU | A host that ends in `.s3.eu-central-1.amazonaws.com` |

#### Send the email with its files

```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: receipt-1042" \
-d '{
"to": "jane@example.com",
"template_key": "receipt",
"subject": "Your receipt for order 1042",
"html": "<p>Thanks for your order. Your invoice and our terms are attached.</p>",
"attachments": [
{"sha256": "d00ca3c2296eace098658a8576dafec8a959f5da536a51c8f9d060c00e427252", "filename": "invoice-1042.pdf", "content_type": "application/pdf"},
{"sha256": "aba4cc7ef2a4423000b9ec5269993717c92b29f092eac5b791c7e9ce24a69306", "filename": "terms.pdf", "content_type": "application/pdf"}
]
}'
```

For a complete script, see the [receipt with a PDF](/docs/workflows/transactional-emails/examples#receipt-with-a-pdf) example.

### Checks for files

These checks apply to the upload route and to sends with `attachments`, in addition to the [errors](/docs/workflows/transactional-emails#errors) of every send. After a `400`, a `404`, or a `429`, PostHog sends nothing. The body of a `400` or a `429` has `why` and `fix` in addition to `code` and `attr`, which tell you the cause and what to do.

| Status | Code | Route | Cause | What to do |
| --- | --- | --- | --- | --- |
| `400` | `field_not_accepted` | Both | PostHog can't send files for your project yet. | Send the email without files. |
| `404` | `not_found` | Upload | Content from your code is off for your project, or your PostHog version has no upload route. | Send the email without files. |
| `400` | `too_many_attachments` | Both | The email has more than 10 files. | Send 10 files at most. Link to other files from the email body. |
| `400` | `message_too_large` | Both | The email is over 40 MB after Base64 encoding. On the upload route, `attr` names the largest file, such as `files__1__size`. | Send fewer or smaller files, or link to large files from the email body. |
| `400` | `attachment_not_uploaded` | Send | Your project has no uploaded file with this SHA-256. The upload didn't finish, the file expired, or a different project uploaded it. `attr` names the file, such as `attachments__0__sha256`. | Ask where to upload again, upload the file, and send the email again. |
| `400` | `blocked_file_type` | Send | Mailbox providers block this file type, such as `.exe`. | Send a different file type, or link to the file from the email body. |
| `400` | `unsafe_filename` | Send | `filename` has a path or a line break. | Send only the file name, such as `invoice.pdf`. |
| `400` | `not_mime_type` | Send | `content_type` isn't a MIME type, or it has a `name` or `filename` parameter. | Send a type such as `application/pdf` or `text/calendar; method=REQUEST`, or leave out `content_type`. Set the file name in `filename`. |
| `400` | `unsafe_content_id` | Send | `content_id` has `<`, `>`, or a line break. | Send only the ID, such as `logo`. |
| `400` | `content_too_large` | Both | The request body is over 20 MB. | Make the request smaller. Files go to the upload URL, never in the request body. |
| `429` | `upload_quota_exceeded` | Upload | Your project asked to upload all the file bytes that it can upload today. A file counts when PostHog gives you its upload URL, even if you don't upload it, and again each time that you ask for a new URL. The day starts at midnight UTC. | Send files that PostHog already has, because they don't count. Link to new files from the email body. Or ask again after midnight UTC. |
| `503` | `delivery_unavailable` | Both | PostHog couldn't get to its file storage. On a send, it can also mean that PostHog couldn't confirm that it queued the email, so PostHog can still send it. See [`delivery_unavailable`](#delivery_unavailable). | Send the same request again after a short wait. For a send, use the same `Idempotency-Key`, so that the email doesn't go out twice. |

- **Inline images** - Set `content_id` on a file, and use it in the HTML as `<img src="cid:logo">`.
- **Blocked files** - PostHog refuses file types that mailbox providers block, such as `.exe`, and a filename with a path.
- **Older PostHog versions** - A PostHog version that doesn't take these fields yet refuses the send with a `400` that names the field. PostHog then sends nothing, and never sends the email without the field.
Other problems with `attachments` or `files`, such as a hash that isn't 64 lowercase hex characters, return the code `invalid_request`, and `attr` names the field.

### Checks for content from your code

Expand Down
31 changes: 30 additions & 1 deletion contents/docs/workflows/transactional-emails/dotnet.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ Ask me for the template ID and the names of its variables. Don't create the API
## Install

```bash
dotnet add package PostHog --version 2.24.0
dotnet add package PostHog --version 2.25.0
```

For ASP.NET Core, install `PostHog.AspNetCore` instead.
Expand Down Expand Up @@ -214,6 +214,35 @@ public async Task<string> SendPasswordResetAsync(User user, string html, string

`SendTemplateEmailOptions` sends a template made in PostHog, and `SendCodeEmailOptions` sends your content. Content from your code takes no template data, 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.

### Copies and attachments

Copies and attachments need version 2.25.0 or later.

`SendCodeEmailOptions` takes `Cc`, `Bcc`, and `Attachments`. `Cc` and `Bcc` are lists of plain addresses. Each attachment is an `EmailAttachment` with a `Filename` and the file's bytes as `Content`, and optionally `ContentType` and `ContentId`. The client gets the SHA-256 of each file, uploads the files that PostHog doesn't have yet, and then sends the email. An email can have 10 files and 40 MB per message after Base64 encoding, about 29 MB of files. The client checks this limit before it uploads a file. If an upload fails, the send fails with an error, and PostHog sends nothing. The client uploads the files to Amazon S3, not to the PostHog host. If your network limits outgoing traffic, [allow the upload host](/docs/workflows/transactional-emails/api#allow-the-upload-host). A template send takes none of these fields, because the template uses its own settings.

```csharp
var result = await emails.SendAsync(new SendCodeEmailOptions
{
To = user.Email,
TemplateKey = "receipt",
Subject = "Your receipt",
Html = html,
Cc = ["billing@example.com"],
Attachments =
[
new EmailAttachment
{
Filename = "receipt.pdf",
Content = await File.ReadAllBytesAsync(pdfPath),
ContentType = "application/pdf",
},
],
DistinctId = user.Id,
});
```

See [checks for files](/docs/workflows/transactional-emails/api#checks-for-files) for the errors that PostHog returns for files.

### Open and click tracking

`Sensitive` and `Tracking` need version 2.24.0 or later.
Expand Down
22 changes: 22 additions & 0 deletions contents/docs/workflows/transactional-emails/elixir.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,28 @@ end

A send has `template_id` or `template_key`, not both. Content from your code takes no `template_data`, 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.

### Copies and attachments

Copies and attachments need version 2.22.0 or later.

Content from your code also takes `cc`, `bcc`, and `attachments`. `cc` and `bcc` take one address or a list of plain addresses. Each attachment is a map with `filename`, the file's bytes as `content`, and optionally `content_type` and `content_id`. The SDK gets the SHA-256 of each file, uploads the files that PostHog doesn't have yet, and then sends the email. An email can have 10 files and 40 MB per message after Base64 encoding, about 29 MB of files. The SDK checks this limit before it uploads a file. If an upload fails, `send` returns an error, and PostHog sends nothing. The SDK uploads the files to Amazon S3, not to the PostHog host. If your network limits outgoing traffic, [allow the upload host](/docs/workflows/transactional-emails/api#allow-the-upload-host).

```elixir
PostHog.Emails.send(
to: user.email,
template_key: "receipt",
subject: "Your receipt",
html: html,
cc: "billing@example.com",
attachments: [
%{filename: "receipt.pdf", content: File.read!(pdf_path), content_type: "application/pdf"}
],
distinct_id: to_string(user.id)
)
```

See [checks for files](/docs/workflows/transactional-emails/api#checks-for-files) for the errors that PostHog returns for files.

### Open and click tracking

`sensitive` and `tracking` need version 2.21.0 or later.
Expand Down
Loading
Loading