Send transactional emails with Node.js
Contents
The Node.js SDK sends transactional emails from version 5.61.2. You need a transactional email and a project secret API key first. The quick start sets up both and sends a test email in a few minutes.
Prompt for your coding agent
Paste this into Claude Code, Cursor, or another coding agent. It sends the agent to this page and gives it the rules that agents often get wrong.
Install
Set up the client
Add your project secret API key to the PostHog client that your app already has. The project token keeps capturing events. The secret key sends email, so keep it on your server and never ship it to the browser.
Keep enableLocalEvaluation: false unless this client also evaluates feature flags locally. With local evaluation on, the client polls flag definitions with the secret key, and a key with only the Transactional email scope can't read them. If you do evaluate flags locally with this client, give the key the Feature flag read scope too.
Shut the client down when your app exits, not after each send. If your app sends through a reverse proxy or a self-hosted PostHog, see use a proxy or self-hosted PostHog.
Send an email
send doesn't throw. It returns { data, error }. data.id is the message ID. error is an EmailError, and its code is one of the codes in the errors table.
A network_error or a 503 doesn't mean that the email failed, because PostHog can still have queued it. Retry with the same idempotency key. If you resend the email through another email provider, the recipient can get it twice.
Timeouts and retries
The client retries for you. It retries network errors, rate limits, an in-flight idempotency key, and a 503 up to two times, with the same idempotency key. It waits about 1 second and then 2 seconds, or as long as the server's Retry-After asks, up to 30 seconds. When Retry-After asks for more than 30 seconds, the client returns the error at once. Each request times out after 15 seconds.
To change this, set emailRequestTimeoutMs and emailRequestMaxRetries on the client, or maxRetries on one send.
Send from a background job
Send from a job queue when the email shouldn't slow down the request, such as an email address check after a signup. Let the queue own the retries:
- Set
maxRetries: 0, so that the client doesn't retry inside the job. - Keep the idempotency key the same on every attempt.
- Finish the retries within 24 hours. PostHog remembers an idempotency key for 24 hours after it queues the email, so a later retry can send the email twice.
- Retry only the errors that a later attempt can fix. Every other code fails the same way again.
This example uses BullMQ. Add the job with attempts and a backoff, so that BullMQ retries it. Five attempts with this backoff finish within minutes.
The worker sends the email:
Link emails to your users
Pass the distinct ID that you pass to identify() as distinctId. This is usually your user's ID. PostHog then records the email's engagement events, such as sent, opened, and clicked, on that user's person, in the same timeline as their product events.
- No distinct ID – PostHog records the email on a person whose distinct ID is the email address. That's a different person from your user, unless your app identifies users by their email address.
- Not a user yet – For an invite, or an email address check before the account exists, leave
distinctIdout.
Send content from your code
From version 5.62.0, send also takes content that your code rendered, with no template in PostHog. Use it when your emails already live in your codebase, or when you move from another email provider. Name the email with templateKey, and pass subject with html, text, or both. PostHog sends the content as you wrote it.
From version 5.69.0, you can pass a React Email component as react, in place of html. The SDK renders its HTML and plain text with @react-email/render, which you install next to posthog-node:
templateKey– Names the email, such aspassword-reset. PostHog groups the email's metrics and sends under this key, so use the same key every time you send this email.react– A React Email element. See send React Email components for the install, the errors, and Next.js.text– If you leave it out, PostHog makes it fromhtml, or the SDK makes it from the renderedreactelement.from– Needs an address on a domain that your project verified. Without it, PostHog uses the project's default sender.replyTotakes one address or a list.sensitive– Marks the email as sensitive, for login links, reset tokens, or codes. If you leave it out, the email takes the setting of the template with the same key, or is sensitive when no template has that key. See sensitive emails.tracking– What PostHog tracks for the email. If you leave it out, PostHog tracks opens and captures engagement events, and tracks clicks only when the email isn't sensitive. See open and click tracking.captureEngagement– Deprecated. Passtracking: { engagement: false }instead. It still works, but a send that sets bothcaptureEngagementandtrackinggets an error.
A send has templateId or templateKey, not both. Content from your code takes no templateData, because PostHog renders nothing. See content from your code for each field and its checks.
Copies and attachments
Copies and attachments need version 5.72.0 or later.
Content from your code takes cc, bcc, and attachments:
ccandbcc– One address or a list of plain addresses.attachments– A list of files. Each file hasfilenameandcontent, and can also havecontentTypeandcontentId.
Pass the file's bytes, such as a Buffer, as content. 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, send returns 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.
See checks for files for the errors that PostHog returns for files.
Open and click tracking
sensitive and tracking need version 5.71.0 or later.
tracking decides what PostHog tracks for an email from your code:
tracking | Opens | Clicks | Engagement events |
|---|---|---|---|
| Left out | On | Follows sensitive | On |
tracking: true | On | On | On |
tracking: false | Off | Off | Off |
An object, such as tracking: { clicks: true } | opens, on by default | clicks, default follows sensitive | engagement, on by default |
- Opens – PostHog adds an open pixel to the email, so it can count opens.
- Clicks – PostHog rewrites the email's links to go through a tracking redirect, so it can count clicks.
- Engagement events – PostHog captures the email's engagement events on the recipient's person. If the project's Capture email engagement events setting is off, PostHog captures nothing, whatever the send says.
If you leave out clicks, PostHog tracks them only when the email isn't sensitive, because a login link or a reset token in a tracked link would go through a redirect. Pass sensitive: false for an email that carries no secrets. For a sensitive email with links that are safe to rewrite, pass tracking: { clicks: true }. A clicks value in tracking wins over sensitive.
With tracking: false, the email goes out without the open pixel and without rewritten links. PostHog can't count its opens and clicks, and captures no engagement events for it.
PostHog refuses tracking: { opens: false, clicks: true }, because click tracking needs open tracking. See open and click tracking for every rule.
Check an email's status
get returns { data, error }. data has id, status, createdAt, statusAt, and reason. See statuses and reasons for what each value means.
A not_found right after a send usually means that the first status hasn't arrived yet. It doesn't mean that the email failed. The client doesn't retry a not_found, so try again a few times with a short wait before you treat the email as missing.
Test without sending email
Pass your own fetch to see exactly what your code sends, with no network call. This tests your integration, not delivery. To test delivery, send a real email to your own address and check the email's Sends tab.
Use a proxy or self-hosted PostHog
On PostHog Cloud, the client sends email to your region's app host, even when host is an ingestion host such as us.i.posthog.com. If host is a reverse proxy or a self-hosted PostHog, set appHost to your PostHog app URL. The client doesn't send email requests to a host that might be a proxy.
With enableLocalEvaluation on, the client still polls flag definitions from host with the secret key. If that host is a proxy that must not see the key, keep enableLocalEvaluation: false.





