Transactional Email API
LetterPress lets other plugins and themes add their own transactional email events. Once registered, an event appears under Templates > Transactional in the same builder as the built-in WordPress emails. Your code can send the event directly, or wrap a notification that another plugin already sends.
The same API powers the bundled LetterPress Pro integrations. Everything here works with the free plugin.
When to register
Register your integration and events on plugins_loaded with priority 20 or later. LetterPress has loaded by then, and the events exist before REST requests, runtime sends, and template seeding run.
add_action( 'plugins_loaded', function () {
add_filter( 'lp_transactional_integrations', 'myplugin_transactional_integrations' );
add_filter( 'lp_transactional_events', 'myplugin_transactional_events' );
}, 20 );
Register an integration group
An integration groups related events in the transactional sidebar. The key is sanitized, so keep it lowercase and prefix it with your plugin name.
function myplugin_transactional_integrations( $integrations ) {
$integrations['myplugin'] = [
'label' => __( 'My Plugin', 'myplugin' ),
'description' => __( 'Account and order updates', 'myplugin' ),
'icon' => 'mail',
'logo' => 'https://example.com/myplugin-logo.svg',
];
return $integrations;
}
| Field | Required | Purpose |
|---|---|---|
label | Yes | Group name shown in the sidebar |
description | No | Short line under the group name |
icon | No | Icon name from the admin icon set |
logo | No | Brand mark URL, shown instead of the icon |
If you skip the integration and register an event without one, the event appears under Other. If you use an integration key that was never registered, the admin shows a separate group named after that key, so register the group when you want a proper label and icon.
Register an event
Events are keyed arrays. The key must contain only lowercase letters, numbers, and underscores, and it should carry your plugin prefix.
function myplugin_transactional_events( $events ) {
$events['myplugin_renewal_reminder'] = [
'integration' => 'myplugin',
'label' => __( 'Renewal Reminder', 'myplugin' ),
'recipient' => __( 'Customer', 'myplugin' ),
'description' => __( 'Reminder sent before a subscription renews.', 'myplugin' ),
'default_subject' => __( 'Your {{plan_name}} plan renews on {{renewal_date}}', 'myplugin' ),
'icon' => 'refresh',
'variables' => [ 'name', 'plan_name', 'renewal_date', 'account_url', 'site_name' ],
'default_document' => __DIR__ . '/templates/renewal-reminder.json',
'sample_context' => [
'plan_name' => 'Pro',
'renewal_date' => 'June 12, 2026',
'account_url' => 'https://example.com/account',
],
];
return $events;
}
| Field | Required | Purpose |
|---|---|---|
label | Yes | Event name on the card and in the builder |
integration | No | Group key. Missing keys use Other. Unknown keys create a group named after the key |
recipient | No | Short label such as Customer, Admin, or User |
description | No | One line explaining when the email is sent |
default_subject | No | Subject used for the starter template and when a saved subject is empty |
icon | No | Icon name from the admin icon set |
variables | No | Merge tags offered in the builder. Must be an array |
default_document | No | Complete starter design, as an array or a path to a readable JSON file |
sample_context | No | Sample values used by Preview and Send test. Must be an array |
The key must match [a-z0-9_]+. Invalid entries are ignored instead of causing a fatal error. When WP_DEBUG is on, LetterPress also raises a _doing_it_wrong notice so the mistake is visible during development.
Starter designs
When default_document is set, LetterPress uses it exactly as registered, with no extra rows prepended or appended. The document is validated and normalized like any builder document.
'default_document' => __DIR__ . '/templates/renewal-reminder.json',
Without it, LetterPress builds a simple transactional starter with a header, one content row, and a footer. The event is still editable right away, just with a generic layout.
If you reuse a built-in event key, your definition replaces that event. This is allowed, but it changes the built-in email for every site running your code.
Create the template
Saving an event definition does not create a template row. Seed it from your activation hook so the event is ready to edit immediately.
register_activation_hook( __FILE__, function () {
lp_ensure_transactional_template( 'myplugin_renewal_reminder' );
} );
lp_ensure_transactional_template() is idempotent in the sense that it is safe to run again. It creates the default template when none exists. If a template already exists, it restores the starter content and subject from your event registration and keeps the current enabled state.
Because of that restore behavior, call it on activation or when you deliberately want to reset the template. Do not call it on every page load, or you will overwrite edits made in the builder. The function returns an array with the template and a message, or a WP_Error if the event key is unknown.
In the admin, an event without a template shows a Create template button. That button performs the same upsert, so activation seeding is convenient but not strictly required.
Send the event
$result = lp_send_transactional(
'myplugin_renewal_reminder',
$customer_email,
[
'name' => $customer_name,
'plan_name' => $plan->name,
'renewal_date' => $renewal_date,
'account_url' => 'https://example.com/account',
'site_name' => get_bloginfo( 'name' ),
]
);
if ( is_wp_error( $result ) ) {
// LetterPress is not active.
} elseif ( ! $result->is_ok() ) {
// Delivery failed. $result->error holds the reason.
}
The fourth argument is optional and sets the sender identity from a subscription source. Pass a source key or source row:
lp_send_transactional( 'myplugin_renewal_reminder', $email, $context, 'myplugin' );
What gets sent
A registered event only sends when its template exists and is enabled. The toggle on the event card controls this, exactly like the built-in WordPress events. Calling lp_send_transactional() for a disabled or missing template returns a failed result instead of falling back to a plain email.
The return value is a Send_Result object, or a WP_Error when LetterPress is not active at all.
| Member | Meaning |
|---|---|
is_ok() | True when the provider accepted the message |
status | sent, failed, or throttle |
error | Human readable failure reason |
error_code | Provider error code when one is available |
permanent | True when the failure should not be retried |
message_id | Provider message ID when one is available |
Blocklisted recipients
Transactional email respects the blocklist too. A blocked address returns a failed result with the code Blocklisted and nothing is sent. If an essential account email must reach an address that was blocked by mistake, a site owner can lift the block under Subscribers > Blocklist, or a developer can bypass it for one send:
add_filter( 'lp_allow_blocklisted_transactional', function ( $allow, $to ) {
}, 10, 2 );
Use that filter only for a specific address or a genuinely essential message. It bypasses the same protection that stops campaigns from mailing bounced and complaining addresses.
Restyle an existing WordPress email
If another plugin already sends the email, do not call lp_send_transactional(). The original sender would still send its own copy. Wrap the notification instead.
Hooks that pass a full email array
Hooks such as wp_new_user_notification_email, password_change_email, and email_change_email pass an array with subject, message, and headers. lp_apply_transactional_email() renders your event and swaps the subject and HTML body. Recipients, CC and BCC, Reply-To, attachments, and other headers stay in place. The From identity and content type are replaced with the LetterPress settings and HTML.
add_filter( 'password_change_email', function ( $email, $user, $userdata ) {
return lp_apply_transactional_email(
'myplugin_password_changed',
$email,
[
'name' => $user['display_name'] ?? $user['user_login'],
'user_login' => $user['user_login'] ?? '',
'site_name' => get_bloginfo( 'name' ),
'site_url' => home_url( '/' ),
],
true
);
}, 20, 3 );
The last argument is true when the notification runs its subject through sprintf(). That is common in WordPress core. Passing true escapes percent signs so the subject survives the extra formatting step.
The email array is returned unchanged when the event has no enabled template. That makes the wrapper safe to leave in place while an event is turned off.
Hooks that pass only the body
Some notifications expose only the message string, for example new_user_email_content. Use lp_replace_transactional_body() for those.
add_filter( 'new_user_email_content', function ( $content, $new_user_email ) {
return lp_replace_transactional_body(
'myplugin_email_change_request',
$content,
[
'name' => wp_get_current_user()->display_name,
'confirm_url' => self_admin_url(
'profile.php?newuseremail=' . rawurlencode( $new_user_email['hash'] )
),
]
);
}, 20, 2 );
The function returns the rendered HTML, or the original content when there is no enabled template. LetterPress also switches that single wp_mail() call to HTML with a plain text alternative and the LetterPress From identity. You do not need to touch phpmailer_init yourself.
Render without sending
lp_render_transactional() returns the rendered parts and sends nothing. It is useful when your plugin needs to place the HTML into another delivery system or inspect the subject first.
$rendered = lp_render_transactional( 'myplugin_renewal_reminder', $context );
if ( $rendered ) {
$html = $rendered['html'];
$text = $rendered['text'];
$subject = $rendered['subject'];
}
It returns null when LetterPress is inactive, the event is unknown, or the template is missing or disabled.
Function reference
| Function | Returns | Purpose |
|---|---|---|
lp_send_transactional( $event_key, $to, $context = [], $source = null ) | Send_Result or WP_Error | Send an enabled event through LetterPress |
lp_render_transactional( $event_key, $context = [] ) | array or null | Render html, text, and subject without sending |
lp_apply_transactional_email( $event_key, $email, $context = [], $sprintf = false ) | array | Apply an event template to a WordPress email array |
lp_replace_transactional_body( $event_key, $content, $context = [] ) | string | Render into a body-only hook and switch that message to HTML |
lp_ensure_transactional_template( $event_key ) | array or WP_Error | Create or restore the event template |
lp_transactional_is_enabled( $event_key ) | bool | Check whether an enabled template exists |
lp_delete_transactional_template( $event_key ) | int | Delete the event's template rows and return the count |
Call lp_transactional_is_enabled() before doing expensive work to build merge values. It is a cheap database lookup compared with rendering or sending.
Filters
| Filter | Arguments | Purpose |
|---|---|---|
lp_transactional_integrations | $integrations | Add or change integration groups |
lp_transactional_events | $events | Add or change event definitions |
lp_transactional_context | $context, $event_key | Enrich merge values at send time |
lp_email_preview_context | $context, $type, $event_key | Override sample values for Preview and Send test |
lp_default_document | $document, $context | Adjust or replace a starter document before it is stored |
lp_allow_blocklisted_transactional | $allow, $to | Bypass the blocklist for one recipient |
The preview filter runs for campaigns and transactional events. Check the $type argument before changing values, and check $event_key when your plugin registers more than one event. The lp_default_document filter also runs for campaign presets, so check the $context value before replacing a document.
Merge values and variables
Event content uses the same {{variable}} syntax as every other LetterPress template. The variables array in the event definition controls which tags the builder offers, but rendering does not require the variable to be listed. Pass any key in $context and it will be available as {{key}}.
Common site values are filled automatically when you do not pass them, including site_name, site_url, year, and admin_email. A missing name falls back to there, which keeps greetings readable for contacts without a stored name.
Clean up on uninstall
Deactivating your plugin leaves its template rows in place but inert. The events are no longer registered, so runtime sends never look them up and the admin hides them. Remove the rows from your uninstall routine when you want a clean removal.
lp_delete_transactional_template( 'myplugin_renewal_reminder' );
This works even when your plugin's registration filters are no longer loaded, so it is safe to call from uninstall.php.
Next steps
- Add new subscribers from PHP with Add a Subscriber.
- Add subscribers to groups from code with the Group Membership API.
- Authenticate external requests with the REST API.
- Review the events already available under Transactional Emails.