Skip to main content

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;
}
FieldRequiredPurpose
labelYesGroup name shown in the sidebar
descriptionNoShort line under the group name
iconNoIcon name from the admin icon set
logoNoBrand 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;
}
FieldRequiredPurpose
labelYesEvent name on the card and in the builder
integrationNoGroup key. Missing keys use Other. Unknown keys create a group named after the key
recipientNoShort label such as Customer, Admin, or User
descriptionNoOne line explaining when the email is sent
default_subjectNoSubject used for the starter template and when a saved subject is empty
iconNoIcon name from the admin icon set
variablesNoMerge tags offered in the builder. Must be an array
default_documentNoComplete starter design, as an array or a path to a readable JSON file
sample_contextNoSample 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.

MemberMeaning
is_ok()True when the provider accepted the message
statussent, failed, or throttle
errorHuman readable failure reason
error_codeProvider error code when one is available
permanentTrue when the failure should not be retried
message_idProvider 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 ) {
return '[email protected]' === $to ? true : $allow;
}, 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​

FunctionReturnsPurpose
lp_send_transactional( $event_key, $to, $context = [], $source = null )Send_Result or WP_ErrorSend an enabled event through LetterPress
lp_render_transactional( $event_key, $context = [] )array or nullRender html, text, and subject without sending
lp_apply_transactional_email( $event_key, $email, $context = [], $sprintf = false )arrayApply an event template to a WordPress email array
lp_replace_transactional_body( $event_key, $content, $context = [] )stringRender into a body-only hook and switch that message to HTML
lp_ensure_transactional_template( $event_key )array or WP_ErrorCreate or restore the event template
lp_transactional_is_enabled( $event_key )boolCheck whether an enabled template exists
lp_delete_transactional_template( $event_key )intDelete 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​

FilterArgumentsPurpose
lp_transactional_integrations$integrationsAdd or change integration groups
lp_transactional_events$eventsAdd or change event definitions
lp_transactional_context$context, $event_keyEnrich merge values at send time
lp_email_preview_context$context, $type, $event_keyOverride sample values for Preview and Send test
lp_default_document$document, $contextAdjust or replace a starter document before it is stored
lp_allow_blocklisted_transactional$allow, $toBypass 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​