Skip to main content

Customizing Email Templates

Benecaster’s default email templates are minimal by design — they work without configuration. When you want to match your brand, change the copy, or reroute sends to a third-party service, every part of the email pipeline is customizable via PHP filters.

Translating an email into another language is a different task from customizing its template. This page covers changing what a template contains; see Translating Benecaster for translating the text it already contains.


Theme Overrides

Copy any template file into your theme (or child theme) to override it. Benecaster checks child theme first, then parent theme, then falls back to its own default.

Three locations, not four — email templates resolve differently from front-end templates. Template Overrides documents a fourth location for add-on-registered directories. Emails have their own resolver and add-on directories do not participate in it, so the chain here is correct as written.

Override path: {theme}/benecaster/emails/{template}.php

Start from the shipped file, and keep its name. The originals live in templates/emails/ inside the plugin. Copy the one you want rather than writing a new file from scratch — it already has the merge tags and markup wired up — and do not rename it. The override is matched on filename alone, so welcome.php overrides the welcome email and welcome-custom.php overrides nothing and fails silently.

The table below is the complete set; there are no email templates that cannot be overridden this way.

Template file What it controls
base.php The outer HTML shell — doctype, <html>, <body>, and the container <table> that wraps all content. The header (logo, accent color, show name) and footer (unsubscribe link, show name, copyright) are rendered inside this file, not as separate partials — override base.php to change any of them. The body slot is injected here.
welcome.php Body of the welcome email
token-reset.php Body of the token reset email
tier-change.php Body of the tier change email
donation-thank-you.php Body of the donation thank-you email sent after a listener logs a donation reference
subscription-receipt.php Body of the billing receipt email sent on each successful payment
renewal-reminder.php Body of the renewal reminder email sent N days before the billing period ends
payment-failed.php Body of the payment failure email sent once per Stripe retry cycle
cancellation-confirmation.php Body of the cancellation confirmation email sent immediately when a subscriber requests cancellation
subscription-cancelled.php Body of the subscription cancelled email sent at terminal cancellation (billing period ended or all retries exhausted)
promote-grace-reminder.php Grace period reminder sent 6–8 days before a promoted subscriber’s feed token expires
promote-grace-expired.php Expiry notice sent when a promoted subscriber’s grace period ends and their token is revoked
manual-grant-expired.php Sent to subscribers whose admin-comped access expires — dispatched by the hourly manual-grant expiry cron
donor-data-erasure.php Sent to a donor confirming erasure of their donation record
guest-password-set.php Body of the set-your-password email sent when the subscribe shortcode creates a WordPress account for a logged-out visitor. Template variable: $reset_url — a single-use password-reset link

manual-grant-expired.php is dispatched by the hourly manual-grant expiry WP-Cron task. On low-traffic sites it may arrive later than expected — see Cron isn’t firing on time →.

Payment-failure emails for your own Benecaster account are not in this list. Notices about a failed Benecaster renewal — the plan you pay benecaster.com for — are sent by benecaster.com to your account’s billing address, not by your site, so there is no local template to override. See Automated Emails. This is separate from payment-failed.php in the table above, which is a subscriber’s payment failing on your site — handled by your membership plugin or Built-in Membership’s Stripe integration, not benecaster.com.

Variables Available in Templates

Every template has access to all resolved merge tags. The specific variables available depend on the email type — see Email Merge Tags for the full list. Common global variables always set:

$subscriber_name   // Subscriber's display name
$subscriber_email  // Subscriber's email address
$show_title        // Show display name
$show_url          // Show's public URL
$site_name         // WordPress site name
$unsubscribe_url   // HMAC-signed unsubscribe URL

Type-specific variables in welcome.php:

$feed_url            // Subscriber's private RSS feed URL
$tier_name           // Subscriber's tier display name
$apple_podcasts_url  // Deep link for Apple Podcasts
$overcast_url        // Deep link for Overcast
$pocket_casts_url    // Deep link for Pocket Casts
$castro_url          // Deep link for Castro
$podcast_addict_url  // Deep link for Podcast Addict (podcastaddict://subscribe?url=...)

The five *_url deep-link variables use non-http(s) schemes. A bare esc_url( $url ) strips a podcast://, overcast://, pktc://, castro:// or podcastaddict:// URL to an empty string, leaving the button’s href blank. The shipped templates escape them with esc_url( $url, BenecasterFeedAppDeepLinks::PROTOCOLS ) — pass the same second argument in any override, or its buttons silently do nothing. A theme override copied before this fix still has empty buttons; the fix is that one-line change per button.

Type-specific variables in token-reset.php:

$feed_url            // New feed URL after reset
$apple_podcasts_url  // Deep link for new URL
$overcast_url
$pocket_casts_url
$castro_url
$podcast_addict_url  // Deep link for Podcast Addict
$custom_message      // Admin's optional message; empty string if not provided

Same rule as welcome.php above: escape these five with esc_url( $url, BenecasterFeedAppDeepLinks::PROTOCOLS ), not a bare esc_url().

Type-specific variables in tier-change.php:

$old_tier_name  // Previous tier display name
$new_tier_name  // New tier display name

Type-specific variables in donation-thank-you.php:

$show_name         // Show display name
$donation_amount   // Formatted amount with ISO 4217 currency code, e.g. "USD 12.50";
                   // empty string when the donor did not provide an amount
$donation_platform // Human-friendly platform label, e.g. "Ko-fi", "PayPal", "Buy Me a Coffee";
                   // falls back to the raw platform slug when the label is not recognised
$site_name         // WordPress site name
$donor_name        // Donor's name; empty string when not collected — always guard against empty
$donor_message     // Message from the donor; empty string when not collected
$donation_date     // Date of the donation, formatted per WordPress date settings

The donation_thank_you email is only sent when the donor provides an email address during submission. It is skipped silently for anonymous donations. Always guard against empty $donor_name and $donor_message in templates — Stripe donations populate these when the donor supplies them; anonymous or link-mode donations do not.

Type-specific variables in subscription-receipt.php:

$amount_formatted    // Invoice amount, e.g. "$12.00"
$invoice_number      // Stripe invoice number, e.g. "INV-0001"
$invoice_hosted_url  // Link to the Stripe-hosted invoice page
$invoice_pdf_url     // Link to download the invoice PDF
$tier_name           // Subscriber's tier display name
$period_end_formatted // Formatted next renewal date

Type-specific variables in renewal-reminder.php:

$tier_name           // Subscriber's tier display name
$renewal_date        // Formatted renewal date
$days_until_renewal  // Integer: number of days until the billing period ends
$amount_formatted    // Renewal amount (reserved; empty string until Email Editor add-on ships)
$billing_interval    // 'month' or 'year'

Type-specific variables in payment-failed.php:

$tier_name           // Subscriber's tier display name

Type-specific variables in cancellation-confirmation.php:

$tier_name           // Subscriber's tier display name
$period_end_formatted // Formatted date when access ends
$resume_url          // URL to resume the subscription — filterable via benecaster_cancellation_confirmation_resume_url

Type-specific variables in subscription-cancelled.php:

$tier_name           // Subscriber's tier display name
$period_end_formatted // Formatted date when access ends (if cancel_at_period_end was set)

All billing lifecycle templates also receive the standard global variables ($subscriber_name, $show_title, $show_url, $site_name, $unsubscribe_url).

Type-specific variables in manual-grant-expired.php:

$tier_name  // Subscriber's tier display name — may be empty when the tier has since been deleted

All standard globals are also available ($subscriber_name, $show_title, $show_url, $site_name, $unsubscribe_url). This template does not receive a $feed_url — the subscriber’s token has already been revoked before the email is dispatched.

Type-specific variables in promote-grace-reminder.php and promote-grace-expired.php:

$tier_name             // The subscriber's original native tier name
$grace_period_end_date // Formatted expiry date
$target_plugin_name    // Name of the destination membership plugin

Available as merge tags: {{tier_name}}, {{grace_period_end_date}}, {{target_plugin_name}}.

The header and footer are rendered inside base.php from a $wrapper_args array, filtered via benecaster_email_wrapper_args. Default keys:

$wrapper_args['logo_url']        // Header logo URL
$wrapper_args['show_name']       // Show name in header/footer
$wrapper_args['accent_color']    // Hex colour for header background
$wrapper_args['footer_text']     // Footer text
$wrapper_args['is_admin_email']  // bool — admin emails omit the unsubscribe link

Use this filter for branding changes — logo, accent color, footer text. Reach for a base.php override only when you need to change the wrapper’s structural markup, since an override means maintaining a copy of that file against future updates.

The Email Editor add-on takes over header and footer rendering when active, so both this filter and any base.php override are bypassed while it is running.


Key Filters

When Your Code Runs: Queued vs. Sent

Benecaster does not send an email in the request that triggers it. It writes the email to a queue, and the actual wp_mail() call happens later, from the benecaster_process_email_queue WP-Cron hook, in a different PHP request. Almost every filter on this page — benecaster_email_headers included — runs at queue time, not at send time.

That gap catches people out in one specific way, so it is worth stating plainly: anything you stash in a static property, a global, or any other request-scoped variable while an email is being queued will not be there when that email is sent. The request it lived in has ended. The failure is silent — mail goes out normally and your data is simply missing, which looks like a problem with your mail service rather than a timing problem.

If you need to run code in the same request as the send — most often to hand Benecaster’s per-email context to a mail plugin’s own outgoing-message filter — hook the benecaster_email_queue_before_send action instead. It fires immediately before each queued email’s wp_mail() call, with the message, the email type, the show and the recipient. See benecaster_email_queue_before_send.

benecaster_mail() is the exception to all of this: it sends immediately, so there is no gap and no cron hop.

Gate Whether an Email Sends

// Suppress all emails of one type:
add_filter( 'benecaster_email_should_send_welcome', function( bool $send, ?int $user_id, ?int $show_id ): bool {
    return false; // suppress welcome; send your own
}, 10, 3 );

Shared filter: benecaster_email_should_send( $should_send, $email_type, $user_id, $show_id ) — fires for every email type.

Important: Never suppress transactional emails (welcome, token_reset, token_revoked) for compliance reasons. Only suppress broadcast or marketing type emails.

This filter is not the same mechanism as the per-show Welcome / Feed URL reset / Tier change switches on Show Settings → Subscription — don’t confuse the two. Those switches are checked before the email is even built, at the point the underlying event fires, so a switched-off email never reaches this filter (or benecaster_email_should_send) at all — there is nothing here to intercept. The welcome switch does not stop a follower’s welcome — a follower has no billing relationship behind which a podcaster could deliver their feed URL another way, so it’s their only route to it (operator ruling). If you need to suppress a follower’s welcome specifically — something the show-level switch cannot do — return false from benecaster_email_should_send_welcome instead. A developer who does this must deliver the feed URL to the follower some other way themselves — the plaintext token is available as the fifth parameter of the benecaster_token_generated action at the moment it fires, and nowhere else afterward.

Change the Feed URL in the Welcome Email

The feed URL placed in the welcome email passes through benecaster_token_url before embedding. Benecaster already has a built-in pretty-permalink format — turn on Use pretty feed URLs at Settings → Permalinks rather than filtering for it. Reach for this filter only for some other URL shape entirely:

add_filter( 'benecaster_token_url', function( string $url, string $token, int $show_id ): string {
    return home_url( '/listen/' . $token );
}, 10, 3 );

If the built-in switch is on, $url arrives already in the pretty form (/podcast/{feed-slug}/feed/{token}/), not the query-string form — a callback written to str_replace( '?token=', …, $url ) or similar finds nothing to replace and silently does nothing. Write your filter to work from $token and $show_id directly, as the example above does, rather than assuming what shape $url arrives in.

Customize Merge Tags per Type

// Add a custom merge tag to welcome emails only:
add_filter( 'benecaster_email_merge_tags_welcome', function( array $tags, ?int $user_id, ?int $show_id ): array {
    $tags['podcast_network_name'] = get_option( 'my_network_name', '' );
    return $tags;
}, 10, 3 );

Shared variant: benecaster_email_merge_tags( $tags, $email_type, $user_id, $show_id ) — fires for every email type. See Email Merge Tags for the full tag catalog.

Tier Change Email

// Suppress tier change email for specific scenarios:
add_filter( 'benecaster_email_should_send_tier_change', function( bool $send, ?int $user_id, ?int $show_id ): bool {
    // return false to suppress
    return $send;
}, 10, 3 );
// Add a custom tag to tier change emails:
add_filter( 'benecaster_email_merge_tags_tier_change', function( array $tags, ?int $user_id, ?int $show_id ): array {
    $tags['support_url'] = 'https://example.com/support';
    return $tags;
}, 10, 3 );
add_filter( 'benecaster_email_wrapper_args', function( array $args, string $email_type, int $show_id ): array {
    $args['logo_url']     = 'https://example.com/custom-email-logo.png';
    $args['accent_color'] = '#1a2e3f';
    return $args;
}, 10, 3 );

Does not fire when the Email Editor add-on is active.

Change the Headers on an Outgoing Email

add_filter( 'benecaster_email_headers', function( array $headers, string $type, ?int $user_id, ?int $show_id ): array {
    if ( 'welcome' === $type ) {
        $headers[] = 'Reply-To: hello@example.com';
    }
    return $headers;
}, 10, 4 );

Per-type variant: benecaster_email_headers_{$type} — same filter for one type only, with $type dropped from the arguments.

On the queued path this filter runs when the email is queued, not when it is sent. Benecaster queues outbound mail and wp_mail() is called later, from cron, in a different PHP request. (benecaster_mail() is the exception — it sends immediately, so the filter runs in your own request there, with the type custom.) The headers you return are stored on the queue row and applied at send time — so do not try to pass state from this filter to a mailer-side filter (phpmailer_init, WP Mail SMTP’s body filters) through a static property or a global. Nothing survives the gap between the two requests. Put what you need into the headers themselves, or hook benecaster_email_queue_before_send, which fires in the sending request — see When Your Code Runs above.

From: set through this filter is dropped. Use benecaster_email_from_name and benecaster_email_from_address instead — those are the supported path, and they are what the Email Editor and the email log read. A message with two From lines is malformed and lands differently in every mail server, so the queue takes the address from its own columns and wins.

The Unsubscribe Headers Your Subscribers Will Notice

Non-transactional Benecaster emails carry RFC 8058 List-Unsubscribe and List-Unsubscribe-Post headers. Gmail, Yahoo and Apple Mail read these and render a native Unsubscribe control next to your name at the top of the message, separate from any link in your footer.

It matters beyond appearances. Gmail and Yahoo have required one-click unsubscribe from bulk senders since 2024, and mail that does not offer it is more likely to be filtered. The unsubscribe URL is HMAC-signed, so the control cannot be used to opt somebody out who did not click it.

Transactional emails carry no unsubscribe headers, and that is deliberate — do not describe this as “all emails”. The protected set is the thirteen types listed under Protecting Transactional Emails from Opt-out Suppression below. Emails with no subscriber context — admin notifications, anything sent without a user and a show — carry none either.

Registering a Custom Email Type

Add-ons that introduce new email types should register them via benecaster_managed_email_types so podcasters can find and edit them:

add_filter( 'benecaster_managed_email_types', function( array $types ): array {
    $types[] = [
        'type'        => 'my_addon_delivery_email',
        'label'       => __( 'Custom Delivery Notice', 'my-addon' ),
        'description' => __( 'Sent when a custom delivery event occurs.', 'my-addon' ),
        'add_on'      => 'my-addon',
    ];
    return $types;
} );

The entry is appended to a list, not keyed by the type string — the slug goes in the type key. Writing $types['my_type'] = [ … ] instead produces an entry every reader of the filter will skip.

Registering is a catalogue entry, not wiring. The filter carries a type, label, description and add-on slug — no subject and no template — so registering a type does not by itself make an email send, render, route or get suppressed. Sending is benecaster_send_email(); opt-out protection is benecaster_transactional_email_types. What registration buys you is that the type is discoverable and editable rather than invisible.

Choose your type string against what is already registered, and check at runtime: apply_filters( 'benecaster_managed_email_types', [] ) returns core’s set plus every active add-on. A collision is silent.

Benecaster’s own manual_grant_expired type is registered this way:

  • Label: Manual Grant Expired
  • Description: Sent to subscribers whose admin-comped access expires — dispatched by the hourly manual-grant expiry cron when the grant’s expiry datetime has passed.
  • Template: templates/emails/manual-grant-expired.php; theme override at [theme]/benecaster/emails/manual-grant-expired.php
  • Merge tags: Standard globals (subscriber_name, show_title, show_url, site_name) plus tier_name (may be empty when the tier has since been deleted)

Once registered, also add the type to benecaster_transactional_email_types if it should bypass broadcast opt-out suppression — see below.


Sending Email From an Add-on

Two public functions, and which one you want depends on whether a subscriber should be able to opt out.

benecaster_send_email() — dispatch a registered type

$queue_id = benecaster_send_email(
    $user_id,
    'my_addon_delivery_email',
    __( 'Your delivery is on its way', 'my-addon' ),
    'my-addon/delivery-notice',
    [ 'show_id' => $show_id ]
);

This is the supported way to perform a send, and it is a pass-through rather than a reimplementation: the should_send gate and unsubscribe suppression, merge tags, template resolution, subscriber locale, every subject/body/from/header filter and both send actions all run exactly as they do for Benecaster’s own emails.

Registering a type does not make it send — the two are separate steps and you need both. Registration makes the type editable in the Email Editor; this function dispatches it.

Do not resolve EmailManager from the container. That was the only route before this function existed, and it made an internal class part of the published surface. The class is not stable; the function is.

Pass show_id in the context whenever you have one. Unsubscribe enforcement needs both the user and the show — omit the show and a subscriber who opted out of that show still gets the mail.

The return value is a queue row ID, not a delivery result. The queue sends later on cron. A 0 means a filter suppressed the send — most often an opt-out, which is not an error and must not be retried.

benecaster_mail() — one-off mail with the podcast’s identity

benecaster_mail(
    $show_id,
    $recipient_email,
    __( 'Your export is ready', 'my-addon' ),
    '<p>' . esc_html__( 'Here it is.', 'my-addon' ) . '</p>'
);

A raw wp_mail() call from add-on code sends as wordpress@yoursite.com. Benecaster’s sender overrides are not bound as global wp_mail_from / wp_mail_from_name / wp_mail_content_type filters — nothing registers those. Identity is computed inside the dispatch pipeline. That leak is the entire reason this function exists, and it applies to any wp_mail() you have already written in add-on code.

It does not queue, log, gate or suppress. No should_send gate runs, so an unsubscribed recipient is not filtered out, and nothing is written to the email log. Anything sent to subscribers in bulk, or anything a subscriber can opt out of, belongs in benecaster_send_email() — register the type and let the queue do its job. benecaster_mail() is for operator alerts, reports and genuine one-offs.

$show_id is filter context, not a per-show sender setting — there is no such setting. The from-name defaults to the site’s blogname; passing the show lets you vary identity per show through benecaster_email_from_name. A From: header you supply is dropped, so the message never goes out with two.


Broadcast Audience Selection

Benecaster resolves audience slugs to WordPress user ID lists when dispatching broadcast emails. Four built-in slugs are available:

Slug Who it includes
'paying' Paying subscribers only — excludes followers and free-tier bridge members
'followers' Direct followers only
'all' Every active token holder for the show: paying + free-tier bridge members + followers
'tier:SLUG' Active token holders on one tier, e.g. 'tier:gold'

Per-tier sends need no code. Pass 'tier:gold' and Benecaster resolves it. If you have a filter callback that hand-rolls tier: handling, delete it — it duplicates core’s resolution and shadows it.

The slug must match a configured tier slug exactly, and the match is case-sensitive. 'tier:Gold' does not find gold. An unconfigured or wrong-cased slug resolves to an empty list rather than erroring, so a typo sends to nobody and looks indistinguishable from a tier nobody has joined.

'tier:follower' does not reach your followers. Follower tokens carry the tier slug follower, which is not a tier-map entry on a normal install, so it resolves empty. Use 'followers'.

To resolve an audience in your own code, call benecaster_find_audience_user_ids( $show_id, $audience ). It returns the WordPress user IDs the slug reaches and needs nothing but WordPress and Benecaster loaded — useful for a dry-run count before anyone presses Send, an export, or handing the list to an ESP yourself.

An empty result is not distinguishable from an error. A tier nobody holds, a slug the show has not configured, and a typo all come back as an empty list. Do not report “nobody is on that tier” on the strength of that alone.

Unknown slugs resolve to an empty array before the benecaster_broadcast_audience_user_ids filter fires — add-ons can fully define a custom audience this way. Reach for a callback when you want something core has no opinion about, such as an engagement or recency segment:

add_filter( 'benecaster_broadcast_audience_user_ids', function (
    array  $user_ids,
    string $audience,
    int    $show_id
): array {
    // Guard first. Without this you rewrite every audience at once.
    if ( 'joined-last-30-days' !== $audience ) {
        return $user_ids;
    }

    return my_recent_joiner_ids( $show_id, 30 );
}, 10, 3 );

Guard on $audience as the first line of the callback. The filter runs on every audience resolution, built-ins included, so a callback that rewrites the list unconditionally changes 'paying', 'followers', 'all' and every 'tier:' send at once — which is rarely what anyone means and is easy not to notice until a broadcast reaches the wrong people.

That same property is useful on purpose: one callback can drop opt-outs from every audience before the list reaches the email queue or an ESP. Pair it with BenecasterEmailUnsubscribeManager::is_opted_out().

Resolving an audience from inside a callback is safe. Asking for the audience you are currently resolving returns Benecaster’s own answer instead of firing the filter again, so it cannot recurse. A different audience still filters normally — a callback for 'my-segment' may legitimately ask for 'paying'.

See the enumerate-broadcast-audience-recipients recipe for a full working example.


Protecting Transactional Emails from Opt-out Suppression

By default, all email types are subject to opt-out suppression when a subscriber has opted out of broadcast emails — except for a protected set of transactional types. The shipped protected set is the thirteen entries in UnsubscribeManager::TRANSACTIONAL_TYPES:

welcome, token_reset, tier_change, migration_reminder,
plan_bumped, threshold_warning, threshold_crossed, downgrade_available,
subscription_receipt, renewal_reminder, payment_failed,
cancellation_confirmation, subscription_cancelled

The billing lifecycle types are protected because a broadcast opt-out is a marketing preference. It means “stop sending me announcements”, and it is never consent to stop being told about the subscriber’s own money. payment_failed is the one that costs you money directly — a subscriber who is never told their card failed does not fix it, and churns on a dunning cycle they could not see.

A broadcast opt-out is not the only opt-out. An all opt-out still suppresses everything, billing included. That is the level a subscriber has to choose deliberately, and adding a type to the protected set does not make it unsuppressable.

donation_thank_you, promote_grace_reminder, promote_grace_expired and manual_grant_expired are not in the protected set, although earlier versions of this page listed them. They are managed email types and they appear in your email settings, but a subscriber who has opted out of broadcast will not receive them.

If your add-on introduces a custom email type that should bypass broadcast opt-out suppression, register it via the benecaster_transactional_email_types filter:

add_filter( 'benecaster_transactional_email_types', function( array $types ): array {
    $types[] = 'my_addon_delivery_email';
    return $types;
} );

Types added here are still suppressed when the subscriber has opted out of all emails.


Unsubscribe Endpoint

Benecaster provides a built-in unsubscribe endpoint at:

?benecaster_unsub={token}&benecaster_unsub_show={id}&benecaster_unsub_type={broadcast|all}

A {{unsubscribe_url}} merge tag is automatically populated in all subscriber-facing emails. You can replace the default URL with a custom branded opt-out page via the benecaster_email_merge_tags filter — see add-custom-merge-tags-to-emails.

Some emails are deliberately not unsubscribable, and guest_password_set is one of them. It carries a send reason of account provisioning rather than marketing: a subscriber who has opted out of everything still needs to be able to set a password and log in, or they cannot reach their own feed URL. Account-provisioning and transactional messages are exempt from unsubscribe handling for that reason — the exemption is narrow and deliberate, not an oversight.

To gate unsubscribe processing (for fraud detection or rate limiting only — never to prevent legitimate opt-outs):

add_filter( 'benecaster_email_unsubscribe_allowed', function( bool $allowed, ?int $user_id, int $show_id, string $type, string $token ): bool {
    // return false only for fraud/abuse prevention
    return $allowed;
}, 10, 5 );

See Unsubscribe Handling for the subscriber-facing flow.


REST Endpoints

For programmatic configuration (integration tests, headless setups, or custom admin UIs):

Endpoint Purpose
GET /benecaster/v1/email-settings Read current email settings including emails_per_hour and queue depth
POST /benecaster/v1/email-settings Update email settings — accepts emails_per_hour (int, 10–2000)

Both require manage_options capability and a valid WP REST nonce.

See Also

Need this built rather than just documented? See our services →