Skip to content
ThemesIonic — home
WooCommerce

Adding Custom Fields to Emails with woocommerce_email_order_meta

The action fires in almost every order email, in both HTML and plain text, for the customer and the admin. Output that ignores those arguments ends up in the wrong email or prints raw tags in plain text.

5 min read intermediate

woocommerce_email_order_meta is the action WooCommerce fires in each order email between the order table and the customer details, and anything you echo there appears in the email. It passes four arguments: the WC_Order, a boolean $sent_to_admin, a boolean $plain_text, and the WC_Email object being sent. Most broken snippets fail on those three extra arguments — they print the same HTML into every email, for every recipient, including the plain-text versions.

The simple route: the fields filter

WooCommerce hooks its own WC_Emails::order_meta() to this action. That method applies woocommerce_email_order_meta_fields and prints each field correctly for HTML and plain text, skipping any whose value is empty. For a label and a value, filter that instead of writing markup:

add_filter( 'woocommerce_email_order_meta_fields', 'ti_email_delivery_date', 10, 3 );

function ti_email_delivery_date( $fields, $sent_to_admin, $order ) {
	$fields['ti_delivery_date'] = array(
		'label' => __( 'Delivery date', 'ti' ),
		'value' => $order->get_meta( '_ti_delivery_date' ),
	);

	return $fields;
}

The filter's arguments are in a different order from the action's: fields, $sent_to_admin, then the order. And it does not receive the email object, so it cannot tell a processing email from a refund. When that matters, use the action.

The action, done properly

add_action( 'woocommerce_email_order_meta', 'ti_email_gift_note', 20, 4 );

function ti_email_gift_note( $order, $sent_to_admin, $plain_text, $email ) {
	if ( ! $email instanceof WC_Email ) {
		return;
	}

	if ( ! in_array( $email->id, array( 'customer_processing_order', 'customer_completed_order', 'new_order' ), true ) ) {
		return;
	}

	$note = $order->get_meta( '_ti_gift_note' );

	if ( '' === $note ) {
		return;
	}

	if ( $plain_text ) {
		echo "\n" . esc_html__( 'Gift note', 'ti' ) . ': ' . esc_html( $note ) . "\n";
		return;
	}

	printf(
		'<h2>%s</h2><p>%s</p>',
		esc_html__( 'Gift note', 'ti' ),
		nl2br( esc_html( $note ) )
	);
}

Three details carry the weight:

  1. accepted_args of 4. Without it WordPress passes only the order, and because the function declares four required parameters PHP throws a "Too few arguments" error — the email fails to render instead of just missing the field.
  2. The email ID list. The action fires in the new order, processing, completed, on-hold, cancelled, failed, refunded, invoice and customer note emails, plus the POS emails. A gift note in a refund notification is at best noise. Common IDs are new_order, customer_processing_order, customer_completed_order, customer_on_hold_order and customer_invoice.
  3. The plain-text branch. Plain-text emails are sent as-is; an <h2> printed there arrives as literal angle brackets. WooCommerce sends plain text when the store's email type is set to plain text or multipart, so test both if the setting is not HTML.

$sent_to_admin tells you whether the recipient is the store or the customer. Use it for internal data — a picking location, a fraud score — that should be in the admin's new order email and never in the customer's.

Priority and position

WooCommerce's own fields print at priority 10. A priority below 10 puts your output above them, above 10 below them. The hook as a whole sits after the order details table and before the billing and shipping addresses in every core template that fires it, both the classic templates and the block email templates introduced with the email editor. If you need the data inside the order table instead — as an extra row beside the totals — that is the woocommerce_get_order_item_totals filter, not this action.

The value has to be on the order first

This action only reads. Data collected at checkout must be saved onto the order before the first email is generated, which happens when the order reaches processing or on-hold, usually in the same request as payment. Save it in woocommerce_checkout_create_order or woocommerce_checkout_update_order_meta; a value written later, for example from woocommerce_thankyou, arrives after the email has gone.

The order passed to the action is the one being emailed, already loaded. Read from it directly rather than calling wc_get_order() again, and prefer $order->get_meta() over get_post_meta(): on stores using the HPOS orders table, order meta lives in its own table, and unless compatibility sync to the posts tables is switched on, the post-meta call returns nothing even though the admin screen shows the value. get_meta() reads the right storage either way.

Line-item data — a personalisation on one product — belongs on the order item, where it shows in the order table automatically once it is stored as item meta. Repeating it here as order meta duplicates it.

Overridden templates

If the theme has copied an email template into yourtheme/woocommerce/emails/ from an old WooCommerce version and dropped the do_action( 'woocommerce_email_order_meta', … ) line, nothing hooked here appears in that email, and only that one. WooCommerce › Status lists outdated template overrides; check it before debugging the callback.

When the email itself is missing

None of this helps if the email is not sent at all. A field that is missing in every email because no email arrives is a delivery problem, covered in WooCommerce emails not sending and, underneath it, wp_mail.

Common mistakes

  • Registering the action with one argument and then using $email or $plain_text.
  • Echoing HTML without a plain-text branch.
  • Printing into every order email, including cancellations and refunds.
  • Escaping nothing: order meta from checkout is customer input.
  • Testing only with the email preview, which uses an unsaved dummy order whose meta is empty.

Verify

  1. Place a test order with the field filled and check the customer processing email and the admin new order email.
  2. Switch the email type to plain text, resend the notification from the order screen, and check there are no tags.
  3. Refund the test order and confirm the field is absent from the refund email if you excluded it.
  4. Check WooCommerce › Status for outdated email template overrides.

Frequently asked

The three usual reasons: the callback declares four parameters but was registered without accepted_args of 4, so PHP throws a "Too few arguments" error and the email fails, the value is empty because the meta key is wrong, or the theme overrides the email template with an old copy that does not call the hook.
The preview renders a dummy order that is never saved and has no ID, so order meta you read with get_meta() is empty there. Send a real test order or resend an order notification from the order screen to see the field.
Use the filter for simple label and value pairs: WooCommerce handles HTML and plain text and skips empty values for you. Use the action when you need your own markup, such as a table or a note block.

Related guides