Commit 222e596bf87 for woocommerce

commit 222e596bf879a1feb73af8dd74e54e9d3a8214b9
Author: Hannah Tinkler <hannah.tinkler@gmail.com>
Date:   Thu Sep 24 18:00:37 2026 +0100

    Track and expose the last send time for each push token (#67620)

    * Track and expose the last send time for each push token

    The first fork in every "I didn't get a notification" escalation is whether we
    ever targeted the device at all. Today nothing is written back to a token when
    it is used, so a token included in a hundred sends looks identical to one that
    has never been touched, and that question can't be answered.

    This stamps a `last_send_at` meta value on every token WPCOM accepted, and
    returns it from the tokens index endpoint.

    Written only on success, so the value reads unambiguously as "WPCOM accepted a
    payload containing this token". It is not a delivery receipt — what happens
    between WPCOM and the device is downstream of anything this plugin observes —
    and the getter documents that so the diagnostics page doesn't overstate it.

    The write is two fixed queries (a delete plus a single multi-row insert) rather
    than one `update_post_meta()` per token, so the cost stays constant instead of
    growing with the number of registered devices. It is also non-fatal: not knowing
    when a token was last used is a diagnostic gap, never a reason to fail a
    notification that was actually delivered.

    `build_meta_array_from_token()` deliberately omits the key, so the app
    re-registering a device cannot clobber the send history.

    * Buffer and update in place when recording push token send times

    The previous version wrote on every notification, deleting and reinserting the
    meta row for every token each time. Two problems on the busiest write path in
    the feature, both versions of what needed the hotfix in #66786.

    Delete-then-insert on the same rows consumes `meta_id` values permanently and
    fragments the primary key, for no benefit. Existing rows are now updated in
    place. Which rows exist is established with one indexed SELECT, because
    `wp_postmeta` has no unique key on `(post_id, meta_key)` to upsert against.

    The `IN` list and the placeholder count also grew with the number of registered
    devices. Both are now chunked, so `$wpdb->prepare()` does bounded work and the
    optimizer keeps choosing the `post_id` index.

    Writes are buffered and flushed once on shutdown rather than once per
    notification. A single loopback request carries every notification a store
    event produced, so a bulk order update previously repeated the same write
    dozens of times against the same few tokens. Cost is now a function of the
    request rather than of the notification count.

    Precision is unchanged. The buffer keeps each token's own most recent send
    time, and stamps written in different seconds are grouped so no token inherits
    another's timestamp.

    * Address review on push token send-time recording

    - A failed SELECT is no longer read as "no rows exist". `get_col()` returns an
      empty array on failure as well as on no match, so one failed read would have
      sent every token down the INSERT branch and duplicated rows that nothing
      removes.
    - Buffered stamps are also flushed on `action_scheduler_after_execute`. The
      safety net and retry paths run under queue runners that are routinely killed
      on a time limit, and shutdown functions do not run on a kill.
    - The flush registration flag is cleared when the buffer is written, so a later
      record re-asserts both hooks rather than assuming its flush is scheduled.
      Both hooks stay registered for the request either way, so this drops nothing
      without it; `add_action()` is idempotent here, so re-asserting is free.
    - Adds a regression test pinning the two guards ahead of the date parse.
      `date_create_immutable()` rejects only genuinely unparseable strings: it
      reads an empty value as the current time and the MySQL zero date as year
      -0001. Folding those guards away would report an unsent token as sent right
      now.

    The timestamp format and the `*_gmt` rename moved to the PR that introduces
    those fields, so each lands correctly named where it is added.

    Cache invalidation is left as it was, deliberately. `wp_cache_delete_multiple()`
    on the whole entry is exactly what `update_metadata()` does for every
    `update_post_meta()` call in WordPress. Patching the entry in place would be a
    read-modify-write on a shared key with no compare-and-swap, so a concurrent
    update to any other meta key on the same token would be resurrected.

    * Harden the last-send write against errors and failed reads

    Three findings from review.

    Catch `Throwable` rather than `Exception` around the chunk write. The docblock
    promises this can never turn a delivered notification into a failed one, and
    `Exception` does not deliver that, because Error and TypeError do not extend it.
    The Action Scheduler hook made it reachable: `action_scheduler_after_execute`
    fires between an action running and `mark_complete()`, inside the runner's own
    Throwable catch, so an Error escaping the flush means the notification was sent,
    the stamps were lost, and the action is recorded as failed.

    Check the prepared statement before reading. `prepare()` returns null on a
    placeholder mismatch, and `get_col( null )` skips the query and reads
    `last_result` from whatever ran before. `query_or_warn()` widens to `?string`
    for the same reason; its `string` type was itself a route to the TypeError
    above.

    Run the read through `query()` and check its return, rather than inferring
    failure from `last_error` afterwards. `wpdb::query()` returns false before it
    clears the previous error when the `query` filter empties the statement, so
    `last_error` cannot see that case at all. A test written for this caught the
    gap, which the original `last_error` guard did not close: a failed read still
    sent every token down the INSERT branch and duplicated rows that `wp_postmeta`
    has no unique key to prevent.

    Adds tests for all three, including the Action Scheduler flush, which exists
    only for a path a hook creates and would otherwise fail silently if removed.

    * Match the new timestamp format for last_send_at_gmt

    The base branch changed created_at_gmt and last_confirmed_at_gmt from RFC3339
    with a +00:00 offset to Y-m-d\TH:i:s, which is what wc_rest_prepare_date_response()
    gives every other _gmt field in the Woo REST API. last_send_at_gmt was written
    before that change and still used the old format, so the response returned three
    dates in two different formats.

    Adds last_send_at_gmt to the index schema, which the base branch introduced and
    which lists every field the response returns.

    * Address review on the last sent time

    Three things raised in review.

    The three new warnings did not set the source context key, so they went to the
    default log rather than the push notifications one. That log is the only place
    the flush reports a failure, since the buffer is cleared before the write and
    the stamp is lost either way.

    Renames last_send_at_gmt to last_sent_at_gmt, matching last_confirmed_at_gmt on
    the same response. The rename covers the property, the accessors, the postmeta
    key and the methods and constants around them, so one name is used at every
    layer. Nothing has shipped, so no migration is needed.

    Declares the field as "type": ["string", "null"] with "format": "date-time".
    date-time is a JSON Schema format, not a type.

    * Stop the last-sent field claiming more than it knows

    A failed stamp is logged and swallowed, so a null last_sent_at_gmt can mean
    the record failed rather than that nothing was sent. The schema said only the
    latter, which would have support reading a broken write path as a device we
    never targeted. Failure logs now carry a token count instead of up to a
    hundred ids per line, and the comment on the flush no longer claims the reset
    can re-arm the shutdown hook, which WP_Hook does not allow.

    * Fold the statement helper into its two call sites

    query_or_warn() cost more lines than it saved across two call sites, and its
    two failure paths logged different messages for the same event, so a search
    for one missed the other. Every failed write now logs through
    warn_last_sent_at_not_recorded() with the cause in the error field.

    * Cover a stamp write that fails without throwing

    wpdb::query() returns false rather than throwing when the query filter empties
    a statement, so the return value has to be checked for the lost stamp to reach
    the log at all. That branch had no test, and the log is the only signal that
    stamps are not being recorded.

    * Stop an older send time replacing a newer one

    Each request captures its timestamp when it dispatches and writes it on
    shutdown, so a slow request could reach the update after a later one had
    already written a newer value, moving the field backwards. The update now
    matches only rows holding an older value.

    * Log every unrecorded stamp under one message

    The Throwable catch in the flush logged "Failed to record" while the query
    checks logged "Could not record", so a search for one missed the other. Both
    now log the same message with the cause in the error field.

    * Cover the stored time advancing on a later push token send

diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushTokenRestController.php b/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushTokenRestController.php
index 3f8fcfc4c39..43975b62ba7 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushTokenRestController.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushTokenRestController.php
@@ -420,6 +420,13 @@ class PushTokenRestController extends RestApiControllerBase {
 						'context'     => array( 'view' ),
 						'readonly'    => true,
 					),
+					'last_sent_at_gmt'      => array(
+						'description' => __( 'The date a notification for this token was last sent to WordPress.com, as GMT. This records that WordPress.com accepted the payload, not that the device received it. Null when no send has been recorded, which also covers a send whose record failed.', 'woocommerce' ),
+						'type'        => array( 'string', 'null' ),
+						'format'      => 'date-time',
+						'context'     => array( 'view' ),
+						'readonly'    => true,
+					),
 				),
 			)
 		);
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/DataStores/PushTokensDataStore.php b/plugins/woocommerce/src/Internal/PushNotifications/DataStores/PushTokensDataStore.php
index 3e362ab9710..56cc73b1630 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/DataStores/PushTokensDataStore.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/DataStores/PushTokensDataStore.php
@@ -14,6 +14,7 @@ use Automattic\WooCommerce\Internal\PushNotifications\Exceptions\PushTokenInvali
 use Automattic\WooCommerce\Internal\PushNotifications\Exceptions\PushTokenNotFoundException;
 use Automattic\WooCommerce\Internal\PushNotifications\PushNotifications;
 use Exception;
+use Throwable;
 use WC_Data_Exception;
 use WP_Http;
 use WP_Query;
@@ -40,6 +41,36 @@ class PushTokensDataStore {
 	 */
 	private ?bool $has_tokens = null;

+	/**
+	 * Buffered last-send stamps awaiting a write, as token post ID => GMT
+	 * datetime. Holds the most recent time recorded for each token this
+	 * request. Flushed by {@see self::flush_last_sent_at()} on shutdown.
+	 *
+	 * @var array<int, string>
+	 */
+	private array $pending_last_sent_at = array();
+
+	/**
+	 * Whether the shutdown flush for `$pending_last_sent_at` has been registered.
+	 *
+	 * @var bool
+	 */
+	private bool $last_sent_at_flush_registered = false;
+
+	/**
+	 * How many tokens to write per statement when flushing last-send stamps.
+	 */
+	const LAST_SENT_AT_CHUNK_SIZE = 100;
+
+	/**
+	 * Meta key holding the GMT datetime of the last successful send to WPCOM.
+	 *
+	 * Deliberately absent from `build_meta_array_from_token()`: it is written
+	 * only by `record_last_sent_at()`, so an unrelated token update (e.g. the app
+	 * re-registering with a new locale) cannot clobber it.
+	 */
+	const LAST_SENT_AT_META_KEY = 'last_sent_at_gmt';
+
 	const SUPPORTED_META = array(
 		'origin',
 		'device_uuid',
@@ -47,6 +78,7 @@ class PushTokensDataStore {
 		'platform',
 		'device_locale',
 		'metadata',
+		self::LAST_SENT_AT_META_KEY,
 	);

 	/**
@@ -139,6 +171,7 @@ class PushTokensDataStore {
 		 */
 		$push_token->set_device_locale( $meta['device_locale'] ?? PushToken::DEFAULT_DEVICE_LOCALE );
 		$push_token->set_metadata( $meta['metadata'] ?? array() );
+		$push_token->set_last_sent_at_gmt( $meta[ self::LAST_SENT_AT_META_KEY ] ?? null );

 		/**
 		 * Both timestamps come from the post record rather than meta, because
@@ -353,19 +386,20 @@ class PushTokensDataStore {
 			) {
 				return new PushToken(
 					array(
-						'id'            => $post_id,
-						'user_id'       => $user_id,
-						'token'         => $meta['token'],
-						'device_uuid'   => $meta['device_uuid'] ?? null,
-						'platform'      => $meta['platform'],
-						'origin'        => $meta['origin'],
+						'id'               => $post_id,
+						'user_id'          => $user_id,
+						'token'            => $meta['token'],
+						'device_uuid'      => $meta['device_uuid'] ?? null,
+						'platform'         => $meta['platform'],
+						'origin'           => $meta['origin'],
 						/**
 						 * These meta items were added after the ability to store
 						 * tokens, so may not be available for older tokens. Use
 						 * sensible defaults.
 						 */
-						'device_locale' => $meta['device_locale'] ?? PushToken::DEFAULT_DEVICE_LOCALE,
-						'metadata'      => $meta['metadata'] ?? array(),
+						'device_locale'    => $meta['device_locale'] ?? PushToken::DEFAULT_DEVICE_LOCALE,
+						'metadata'         => $meta['metadata'] ?? array(),
+						'last_sent_at_gmt' => $meta[ self::LAST_SENT_AT_META_KEY ] ?? null,
 					)
 				);
 			}
@@ -521,6 +555,238 @@ class PushTokensDataStore {
 		return $result;
 	}

+	/**
+	 * Records that the given tokens were successfully sent to WPCOM.
+	 *
+	 * Buffers the stamps and writes them once on shutdown rather than per call.
+	 * A single request often processes several notifications — the loopback
+	 * receives every notification a store event produced, and a bulk order
+	 * update can produce dozens — and each one would otherwise repeat the same
+	 * write against the same handful of tokens. Buffering makes the cost a
+	 * function of the request rather than of the notification count, while each
+	 * token still keeps the exact time of its own most recent send.
+	 *
+	 * @since 11.2.0
+	 *
+	 * @param PushToken[] $push_tokens The tokens WPCOM accepted.
+	 * @return void
+	 */
+	public function record_last_sent_at( array $push_tokens ): void {
+		$timestamp = gmdate( 'Y-m-d H:i:s' );
+
+		foreach ( $push_tokens as $push_token ) {
+			$id = $push_token->get_id();
+
+			if ( $id ) {
+				$this->pending_last_sent_at[ $id ] = $timestamp;
+			}
+		}
+
+		if ( empty( $this->pending_last_sent_at ) || $this->last_sent_at_flush_registered ) {
+			return;
+		}
+
+		add_action( 'shutdown', array( $this, 'flush_last_sent_at' ) );
+
+		// The safety net and retry jobs run under an Action Scheduler queue
+		// runner, which is routinely killed on a time limit. Shutdown functions
+		// do not run on a kill, so flush after each action as well.
+		add_action( 'action_scheduler_after_execute', array( $this, 'flush_last_sent_at' ) );
+
+		$this->last_sent_at_flush_registered = true;
+	}
+
+	/**
+	 * Writes the buffered last-send stamps.
+	 *
+	 * Runs on shutdown and after each Action Scheduler action, and is safe to
+	 * call directly to force the write early.
+	 *
+	 * Failure is swallowed: not knowing when a token was last used is a
+	 * diagnostic gap, and must never turn a delivered notification into a
+	 * failed one.
+	 *
+	 * @since 11.2.0
+	 *
+	 * @return void
+	 */
+	public function flush_last_sent_at(): void {
+		// Lets a later `record_last_sent_at()` re-assert the Action Scheduler
+		// hook, which fires once per action. It cannot re-arm `shutdown`:
+		// WP_Hook iterates a copy of its callbacks, so a stamp recorded during
+		// shutdown after this ran is dropped. No path does that today.
+		$this->last_sent_at_flush_registered = false;
+
+		if ( empty( $this->pending_last_sent_at ) ) {
+			return;
+		}
+
+		$pending                    = $this->pending_last_sent_at;
+		$this->pending_last_sent_at = array();
+
+		// Chunked so neither the `IN` list nor the number of placeholders handed
+		// to `$wpdb->prepare()` grows with the number of registered devices. A
+		// large `IN` list can also push the optimizer off the `post_id` index.
+		foreach ( array_chunk( $pending, self::LAST_SENT_AT_CHUNK_SIZE, true ) as $chunk ) {
+			try {
+				$this->write_last_sent_at_chunk( $chunk );
+			} catch ( Throwable $e ) {
+				// Throwable, not Exception. `action_scheduler_after_execute`
+				// fires between the action running and `mark_complete()`, inside
+				// the runner's own Throwable catch, so an Error escaping here
+				// would record a delivered notification's action as failed.
+				$this->warn_last_sent_at_not_recorded( array_keys( $chunk ), $e->getMessage() );
+			}
+		}
+	}
+
+	/**
+	 * Writes one chunk of buffered last-send stamps.
+	 *
+	 * Existing rows are updated in place rather than deleted and reinserted.
+	 * This is the busiest write path in the feature, and a delete/insert cycle
+	 * on the same rows consumes `meta_id` values permanently and fragments the
+	 * primary key, for no benefit — `wp_postmeta` has no unique key on
+	 * `(post_id, meta_key)`, so the rows to update have to be identified first
+	 * either way.
+	 *
+	 * @param array<int, string> $chunk Map of token post ID to GMT datetime.
+	 * @return void
+	 */
+	private function write_last_sent_at_chunk( array $chunk ): void {
+		global $wpdb;
+
+		$post_ids = array_keys( $chunk );
+
+		/**
+		 * The statements below interpolate a placeholder list whose length
+		 * depends on the number of tokens. The interpolated strings are built
+		 * from literals only — never from token data — and every value still
+		 * travels through `$wpdb->prepare()`, which is why the sniffs are
+		 * suppressed rather than the queries being restructured.
+		 */
+		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
+		$select = $wpdb->prepare(
+			sprintf(
+				"SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = %%s AND post_id IN ( %s )",
+				implode( ', ', array_fill( 0, count( $post_ids ), '%d' ) )
+			),
+			array_merge( array( self::LAST_SENT_AT_META_KEY ), $post_ids )
+		);
+
+		// Checked before the read, not after: a null here would leave
+		// `last_result` holding whatever ran previously.
+		if ( ! is_string( $select ) || '' === $select ) {
+			$this->warn_last_sent_at_not_recorded( $post_ids, 'Could not build the query to read existing stamps, skipping chunk.' );
+
+			return;
+		}
+
+		// Run the statement rather than using `get_col()`, whose empty array
+		// means both "nothing matched" and "the read failed". Taking a failed
+		// read as "no rows exist" would insert duplicates that `wp_postmeta`
+		// has no unique key to prevent. `last_error` alone cannot be trusted
+		// either: `wpdb::query()` returns false before it clears the previous
+		// error when the `query` filter empties the statement.
+		$rows = $wpdb->query( $select );
+
+		if ( false === $rows ) {
+			$this->warn_last_sent_at_not_recorded(
+				$post_ids,
+				sprintf(
+					'Could not read existing stamps, skipping chunk. %s',
+					'' !== $wpdb->last_error ? $wpdb->last_error : 'The query did not run.'
+				)
+			);
+
+			return;
+		}
+
+		$existing = array_map( 'intval', wp_list_pluck( (array) $wpdb->last_result, 'post_id' ) );
+
+		// Tokens sent at the same moment share an UPDATE, so this is usually
+		// one query, without giving a token another's send time when a request
+		// spans a second boundary.
+		$by_timestamp = array();
+
+		foreach ( $chunk as $post_id => $timestamp ) {
+			$by_timestamp[ $timestamp ][] = $post_id;
+		}
+
+		foreach ( $by_timestamp as $timestamp => $ids ) {
+			$update_ids = array_values( array_intersect( $ids, $existing ) );
+			$insert_ids = array_values( array_diff( $ids, $existing ) );
+
+			if ( ! empty( $update_ids ) ) {
+				// Advance-only. Each request captures its timestamp when it
+				// dispatches and writes it on shutdown, so a slow request can
+				// reach this after a later one has already written a newer
+				// value. `Y-m-d H:i:s` compares lexicographically in date
+				// order, and a zero-row match returns 0 rather than false.
+				$update = $wpdb->prepare(
+					sprintf(
+						"UPDATE {$wpdb->postmeta} SET meta_value = %%s WHERE meta_key = %%s AND meta_value < %%s AND post_id IN ( %s )",
+						implode( ', ', array_fill( 0, count( $update_ids ), '%d' ) )
+					),
+					array_merge( array( $timestamp, self::LAST_SENT_AT_META_KEY, $timestamp ), $update_ids )
+				);
+
+				if ( ! is_string( $update ) || '' === $update ) {
+					$this->warn_last_sent_at_not_recorded( $update_ids, 'Could not build the update statement.' );
+				} elseif ( false === $wpdb->query( $update ) ) {
+					$this->warn_last_sent_at_not_recorded( $update_ids, $wpdb->last_error );
+				}
+			}
+
+			if ( ! empty( $insert_ids ) ) {
+				$insert_args = array();
+
+				foreach ( $insert_ids as $post_id ) {
+					$insert_args[] = $post_id;
+					$insert_args[] = self::LAST_SENT_AT_META_KEY;
+					$insert_args[] = $timestamp;
+				}
+
+				$insert = $wpdb->prepare(
+					sprintf(
+						"INSERT INTO {$wpdb->postmeta} ( post_id, meta_key, meta_value ) VALUES %s",
+						implode( ', ', array_fill( 0, count( $insert_ids ), '( %d, %s, %s )' ) )
+					),
+					$insert_args
+				);
+
+				if ( ! is_string( $insert ) || '' === $insert ) {
+					$this->warn_last_sent_at_not_recorded( $insert_ids, 'Could not build the insert statement.' );
+				} elseif ( false === $wpdb->query( $insert ) ) {
+					$this->warn_last_sent_at_not_recorded( $insert_ids, $wpdb->last_error );
+				}
+			}
+		}
+		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
+
+		// The rows were written behind the meta API's back, so the cached meta
+		// for these posts is now stale and must be dropped.
+		wp_cache_delete_multiple( $post_ids, 'post_meta' );
+	}
+
+	/**
+	 * Logs that last-send stamps could not be recorded.
+	 *
+	 * @param int[]  $post_ids The token post IDs affected.
+	 * @param string $error    What went wrong, and what was skipped as a result.
+	 * @return void
+	 */
+	private function warn_last_sent_at_not_recorded( array $post_ids, string $error ): void {
+		wc_get_logger()->warning(
+			'Could not record last sent time for push tokens.',
+			array(
+				'source'      => PushNotifications::FEATURE_NAME,
+				'token_count' => count( $post_ids ),
+				'error'       => $error,
+			)
+		);
+	}
+
 	/**
 	 * Returns an associative array of post meta as key => value pairs for the
 	 * keys defined in SUPPORTED_META; missing keys return null. Use
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Entities/PushToken.php b/plugins/woocommerce/src/Internal/PushNotifications/Entities/PushToken.php
index fb69d9f72ca..d6d472583b0 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Entities/PushToken.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Entities/PushToken.php
@@ -180,6 +180,13 @@ class PushToken {
 	 */
 	private ?string $last_confirmed_at_gmt = null;

+	/**
+	 * The date the token was last sent to WPCOM, as a GMT `Y-m-d H:i:s` string.
+	 *
+	 * @var string|null
+	 */
+	private ?string $last_sent_at_gmt = null;
+
 	/**
 	 * Creates a new PushToken instance with the given data.
 	 *
@@ -228,6 +235,10 @@ class PushToken {
 		if ( array_key_exists( 'last_confirmed_at_gmt', $data ) ) {
 			$this->set_last_confirmed_at_gmt( null === $data['last_confirmed_at_gmt'] ? null : (string) $data['last_confirmed_at_gmt'] );
 		}
+
+		if ( array_key_exists( 'last_sent_at_gmt', $data ) ) {
+			$this->set_last_sent_at_gmt( null === $data['last_sent_at_gmt'] ? null : (string) $data['last_sent_at_gmt'] );
+		}
 	}

 	/**
@@ -438,6 +449,20 @@ class PushToken {
 		$this->last_confirmed_at_gmt = $this->validate_gmt_datetime( $last_confirmed_at_gmt );
 	}

+	/**
+	 * Sets the date this token was last sent to WPCOM.
+	 *
+	 * See {@see self::set_created_at_gmt()} for why this bypasses validation.
+	 *
+	 * @param string|null $last_sent_at_gmt A GMT `Y-m-d H:i:s` datetime, or null if never sent.
+	 * @return void
+	 *
+	 * @since 11.2.0
+	 */
+	public function set_last_sent_at_gmt( ?string $last_sent_at_gmt ): void {
+		$this->last_sent_at_gmt = $this->validate_gmt_datetime( $last_sent_at_gmt );
+	}
+
 	/**
 	 * Returns a GMT datetime as `Y-m-d H:i:s`, or null if it is not one.
 	 *
@@ -591,6 +616,22 @@ class PushToken {
 		return $this->last_confirmed_at_gmt;
 	}

+	/**
+	 * Gets the date this token was last sent to WPCOM, as a GMT `Y-m-d H:i:s`
+	 * string, or null if it has never been sent.
+	 *
+	 * This records that WPCOM accepted a payload containing the token. It is
+	 * not a delivery receipt: what happens between WPCOM and the device is
+	 * downstream of anything this plugin can observe.
+	 *
+	 * @return string|null
+	 *
+	 * @since 11.2.0
+	 */
+	public function get_last_sent_at_gmt(): ?string {
+		return $this->last_sent_at_gmt;
+	}
+
 	/**
 	 * Returns this token formatted for the WPCOM push notifications endpoint.
 	 *
@@ -617,7 +658,7 @@ class PushToken {
 	 * Metadata is cast to an object so that an empty value encodes as `{}` rather
 	 * than `[]`, matching the `object` type the schema declares for it.
 	 *
-	 * @return array{user_id: int|null, token: string|null, origin: string|null, device_locale: string|null, id: int|null, device_uuid: string|null, platform: string|null, metadata: stdClass, created_at_gmt: string|null, last_confirmed_at_gmt: string|null}
+	 * @return array{user_id: int|null, token: string|null, origin: string|null, device_locale: string|null, id: int|null, device_uuid: string|null, platform: string|null, metadata: stdClass, created_at_gmt: string|null, last_confirmed_at_gmt: string|null, last_sent_at_gmt: string|null}
 	 *
 	 * @since 11.2.0
 	 */
@@ -631,6 +672,7 @@ class PushToken {
 				'metadata'              => (object) ( $this->metadata ?? array() ),
 				'created_at_gmt'        => $this->to_rest_datetime( $this->created_at_gmt ),
 				'last_confirmed_at_gmt' => $this->to_rest_datetime( $this->last_confirmed_at_gmt ),
+				'last_sent_at_gmt'      => $this->to_rest_datetime( $this->last_sent_at_gmt ),
 			)
 		);
 	}
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationProcessor.php b/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationProcessor.php
index e630110d0a9..e3c9afaed60 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationProcessor.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationProcessor.php
@@ -192,6 +192,9 @@ class NotificationProcessor {
 		$result = $this->dispatcher->dispatch( $notification, $tokens );

 		if ( ! empty( $result['success'] ) ) {
+			// Success only, for the reason {@see PushToken::get_last_sent_at_gmt()} gives.
+			$this->data_store->record_last_sent_at( $tokens );
+
 			$notification->write_meta( self::SENT_META_KEY );
 			$notification->reset_processing_meta();
 			$this->cancel_safety_net( $notification );
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/PushTokenRestControllerTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/PushTokenRestControllerTest.php
index 26d38d82593..2bce0caa21f 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/PushTokenRestControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/PushTokenRestControllerTest.php
@@ -1236,6 +1236,7 @@ class PushTokenRestControllerTest extends WC_Unit_Test_Case {
 				'metadata',
 				'created_at_gmt',
 				'last_confirmed_at_gmt',
+				'last_sent_at_gmt',
 			),
 			array_keys( $schema['properties'] )
 		);
@@ -1613,6 +1614,7 @@ class PushTokenRestControllerTest extends WC_Unit_Test_Case {
 				'metadata',
 				'created_at_gmt',
 				'last_confirmed_at_gmt',
+				'last_sent_at_gmt',
 			),
 			array_keys( $fields )
 		);
@@ -1808,6 +1810,51 @@ class PushTokenRestControllerTest extends WC_Unit_Test_Case {
 		$this->assertNull( $token_data['last_confirmed_at_gmt'] );
 	}

+	/**
+	 * @testdox Should return the last sent time for a sent token and null for an unsent one.
+	 */
+	public function test_index_returns_token_last_sent_at_time(): void {
+		$this->mock_jetpack_connection_manager_is_connected();
+		wc_get_container()->get( PushNotifications::class )->on_init();
+
+		$data_store = wc_get_container()->get( PushTokensDataStore::class );
+
+		$sent = $data_store->create(
+			array(
+				'user_id'       => $this->user_id,
+				'token'         => 'last-send-sent-token',
+				'platform'      => PushToken::PLATFORM_APPLE,
+				'device_uuid'   => 'last-send-sent-uuid',
+				'origin'        => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+				'device_locale' => 'en_US',
+			)
+		);
+
+		$data_store->create(
+			array(
+				'user_id'       => $this->user_id,
+				'token'         => 'last-send-unsent-token',
+				'platform'      => PushToken::PLATFORM_ANDROID,
+				'device_uuid'   => 'last-send-unsent-uuid',
+				'origin'        => PushToken::ORIGIN_WOOCOMMERCE_ANDROID,
+				'device_locale' => 'en_US',
+			)
+		);
+
+		$data_store->record_last_sent_at( array( $sent ) );
+		$data_store->flush_last_sent_at();
+
+		$request = new WP_REST_Request( 'GET', '/wc-push-notifications/push-tokens' );
+		$request->set_param( 'page', 1 );
+		$request->set_param( 'per_page', 100 );
+
+		$by_token = array_column( ( new PushTokenRestController() )->index( $request )->get_data()['tokens'], null, 'token' );
+
+		$this->assertArrayHasKey( 'last_sent_at_gmt', $by_token['last-send-unsent-token'] );
+		$this->assertNull( $by_token['last-send-unsent-token']['last_sent_at_gmt'] );
+		$this->assertNotNull( $by_token['last-send-sent-token']['last_sent_at_gmt'] );
+	}
+
 	/**
 	 * @testdox Should return empty tokens array from the tokens endpoint when no tokens exist.
 	 */
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/DataStores/PushTokensDataStoreTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/DataStores/PushTokensDataStoreTest.php
index 1205e3e1f9a..594d1c5b5d3 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/DataStores/PushTokensDataStoreTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/DataStores/PushTokensDataStoreTest.php
@@ -8,6 +8,7 @@ use Automattic\WooCommerce\Internal\PushNotifications\DataStores\PushTokensDataS
 use Automattic\WooCommerce\Internal\PushNotifications\Entities\PushToken;
 use Automattic\WooCommerce\Internal\PushNotifications\Exceptions\PushTokenInvalidDataException;
 use Automattic\WooCommerce\Internal\PushNotifications\Exceptions\PushTokenNotFoundException;
+use Automattic\WooCommerce\RestApi\UnitTests\LoggerSpyTrait;
 use WC_Unit_Test_Case;

 /**
@@ -16,6 +17,9 @@ use WC_Unit_Test_Case;
  * @covers \Automattic\WooCommerce\Internal\PushNotifications\DataStores\PushTokensDataStore
  */
 class PushTokensDataStoreTest extends WC_Unit_Test_Case {
+
+	use LoggerSpyTrait;
+
 	/**
 	 * Tear down the test case.
 	 */
@@ -1151,6 +1155,399 @@ class PushTokensDataStoreTest extends WC_Unit_Test_Case {
 		$this->assertGreaterThan( $read->get_created_at_gmt(), $read->get_last_confirmed_at_gmt() );
 	}

+	/**
+	 * @testdox Tests a token has no last sent time until it has actually been sent.
+	 */
+	public function test_last_sent_at_is_null_for_a_token_that_has_never_been_sent() {
+		$data_store = new PushTokensDataStore();
+		$push_token = $this->create_test_push_token();
+
+		$this->assertNull( $data_store->read( $push_token->get_id() )->get_last_sent_at_gmt() );
+	}
+
+	/**
+	 * @testdox Tests recording a send stamps every supplied token with the same time.
+	 */
+	public function test_record_last_sent_at_stamps_all_supplied_tokens() {
+		$data_store = new PushTokensDataStore();
+		$first      = $this->create_test_push_token();
+		$second     = $this->create_test_push_token();
+
+		$data_store->record_last_sent_at( array( $first, $second ) );
+		$data_store->flush_last_sent_at();
+
+		$first_send_at  = $data_store->read( $first->get_id() )->get_last_sent_at_gmt();
+		$second_send_at = $data_store->read( $second->get_id() )->get_last_sent_at_gmt();
+
+		$this->assertNotNull( $first_send_at );
+		$this->assertSame( $first_send_at, $second_send_at );
+	}
+
+	/**
+	 * @testdox Tests recording a send twice replaces the stamp rather than accumulating rows.
+	 *
+	 * The batched write bypasses the meta API, so it has to leave exactly one
+	 * row per token behind — a duplicate would make `get_post_meta( …, true )`
+	 * return an arbitrary one of them.
+	 */
+	public function test_record_last_sent_at_replaces_the_previous_stamp() {
+		global $wpdb;
+
+		$data_store = new PushTokensDataStore();
+		$push_token = $this->create_test_push_token();
+
+		$data_store->record_last_sent_at( array( $push_token ) );
+		$data_store->flush_last_sent_at();
+		$data_store->record_last_sent_at( array( $push_token ) );
+		$data_store->flush_last_sent_at();
+
+		$row_count = (int) $wpdb->get_var(
+			$wpdb->prepare(
+				"SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key = %s",
+				$push_token->get_id(),
+				PushTokensDataStore::LAST_SENT_AT_META_KEY
+			)
+		);
+
+		$this->assertSame( 1, $row_count );
+	}
+
+	/**
+	 * @testdox Tests an older stamp does not replace a newer one.
+	 *
+	 * Each request captures its timestamp when it dispatches and writes it on
+	 * shutdown, so a slow request can reach the update after a later one has
+	 * already written a newer value. The update is advance-only so the field
+	 * cannot go backwards.
+	 */
+	public function test_an_older_stamp_does_not_replace_a_newer_one() {
+		$data_store = new PushTokensDataStore();
+		$push_token = $this->create_test_push_token();
+		$newer      = gmdate( 'Y-m-d H:i:s', time() + HOUR_IN_SECONDS );
+
+		update_post_meta( $push_token->get_id(), PushTokensDataStore::LAST_SENT_AT_META_KEY, $newer );
+
+		$data_store->record_last_sent_at( array( $push_token ) );
+		$data_store->flush_last_sent_at();
+
+		$this->assertSame( $newer, $data_store->read( $push_token->get_id() )->get_last_sent_at_gmt() );
+	}
+
+	/**
+	 * @testdox Tests recording a send leaves the rest of the token record untouched.
+	 */
+	public function test_record_last_sent_at_does_not_disturb_other_token_data() {
+		$data_store = new PushTokensDataStore();
+		$push_token = $this->create_test_push_token();
+
+		$data_store->record_last_sent_at( array( $push_token ) );
+		$data_store->flush_last_sent_at();
+		$read = $data_store->read( $push_token->get_id() );
+
+		$this->assertSame( $push_token->get_token(), $read->get_token() );
+		$this->assertSame( $push_token->get_device_uuid(), $read->get_device_uuid() );
+		$this->assertSame( $push_token->get_device_locale(), $read->get_device_locale() );
+		$this->assertSame( $push_token->get_metadata(), $read->get_metadata() );
+	}
+
+	/**
+	 * @testdox Tests updating a token preserves its last sent time.
+	 *
+	 * The app re-registers a device whenever its locale or metadata changes,
+	 * which must not wipe the send history that update path knows nothing about.
+	 */
+	public function test_updating_a_token_preserves_its_last_sent_at_time() {
+		$data_store = new PushTokensDataStore();
+		$push_token = $this->create_test_push_token();
+
+		$data_store->record_last_sent_at( array( $push_token ) );
+		$data_store->flush_last_sent_at();
+		$recorded = $data_store->read( $push_token->get_id() )->get_last_sent_at_gmt();
+
+		$push_token->set_device_locale( 'fr_FR' );
+		$data_store->update( $push_token );
+
+		$this->assertSame( $recorded, $data_store->read( $push_token->get_id() )->get_last_sent_at_gmt() );
+	}
+
+	/**
+	 * @testdox Tests recording a send defers the write until the buffer is flushed.
+	 *
+	 * A request can process many notifications against the same few tokens, so
+	 * the write is buffered and happens once rather than once per notification.
+	 */
+	public function test_record_last_sent_at_defers_the_write_until_flushed() {
+		$data_store = new PushTokensDataStore();
+		$push_token = $this->create_test_push_token();
+
+		$data_store->record_last_sent_at( array( $push_token ) );
+
+		$this->assertNull( $data_store->read( $push_token->get_id() )->get_last_sent_at_gmt() );
+
+		$data_store->flush_last_sent_at();
+
+		$this->assertNotNull( $data_store->read( $push_token->get_id() )->get_last_sent_at_gmt() );
+	}
+
+	/**
+	 * @testdox Tests repeated recording before a flush results in a single write.
+	 */
+	public function test_repeated_recording_before_a_flush_writes_once() {
+		global $wpdb;
+
+		$data_store = new PushTokensDataStore();
+		$push_token = $this->create_test_push_token();
+
+		for ( $i = 0; $i < 5; $i++ ) {
+			$data_store->record_last_sent_at( array( $push_token ) );
+		}
+
+		$data_store->flush_last_sent_at();
+
+		$row_count = (int) $wpdb->get_var(
+			$wpdb->prepare(
+				"SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key = %s",
+				$push_token->get_id(),
+				PushTokensDataStore::LAST_SENT_AT_META_KEY
+			)
+		);
+
+		$this->assertSame( 1, $row_count );
+	}
+
+	/**
+	 * @testdox Tests flushing an already flushed buffer is a no-op.
+	 */
+	public function test_flushing_twice_does_not_write_again() {
+		$data_store = new PushTokensDataStore();
+		$push_token = $this->create_test_push_token();
+
+		$data_store->record_last_sent_at( array( $push_token ) );
+		$data_store->flush_last_sent_at();
+
+		$recorded = $data_store->read( $push_token->get_id() )->get_last_sent_at_gmt();
+
+		$data_store->flush_last_sent_at();
+
+		$this->assertSame( $recorded, $data_store->read( $push_token->get_id() )->get_last_sent_at_gmt() );
+	}
+
+	/**
+	 * @testdox Tests a later send advances the stored time in the existing row.
+	 *
+	 * The row is updated in place so that repeatedly sending to the same device
+	 * does not consume `meta_id` values or churn the primary key on what is the
+	 * busiest write path in the feature. The earlier time is seeded an hour back
+	 * because two sends in the same second write an identical value, which the
+	 * advance-only guard skips. The unrelated row holds the same earlier time so
+	 * the guard would not protect it if the update matched on more than its key.
+	 */
+	public function test_a_later_send_advances_the_stored_time_in_place() {
+		global $wpdb;
+
+		$data_store = new PushTokensDataStore();
+		$push_token = $this->create_test_push_token();
+		$earlier    = gmdate( 'Y-m-d H:i:s', time() - HOUR_IN_SECONDS );
+
+		update_post_meta( $push_token->get_id(), PushTokensDataStore::LAST_SENT_AT_META_KEY, $earlier );
+		update_post_meta( $push_token->get_id(), 'unrelated_meta', $earlier );
+
+		$meta_id = $wpdb->get_var(
+			$wpdb->prepare(
+				"SELECT meta_id FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key = %s",
+				$push_token->get_id(),
+				PushTokensDataStore::LAST_SENT_AT_META_KEY
+			)
+		);
+
+		$data_store->record_last_sent_at( array( $push_token ) );
+		$data_store->flush_last_sent_at();
+
+		$this->assertNotNull( $meta_id );
+		$this->assertGreaterThan( $earlier, $data_store->read( $push_token->get_id() )->get_last_sent_at_gmt() );
+		$this->assertSame( $earlier, get_post_meta( $push_token->get_id(), 'unrelated_meta', true ) );
+		$this->assertSame(
+			$meta_id,
+			$wpdb->get_var(
+				$wpdb->prepare(
+					"SELECT meta_id FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key = %s",
+					$push_token->get_id(),
+					PushTokensDataStore::LAST_SENT_AT_META_KEY
+				)
+			)
+		);
+	}
+
+	/**
+	 * @testdox Tests every token is stamped when the buffer spans more than one chunk.
+	 */
+	public function test_flush_stamps_every_token_across_chunks() {
+		$data_store = new PushTokensDataStore();
+		$tokens     = array();
+
+		for ( $i = 0; $i < PushTokensDataStore::LAST_SENT_AT_CHUNK_SIZE + 5; $i++ ) {
+			$tokens[] = $this->create_test_push_token();
+		}
+
+		$data_store->record_last_sent_at( $tokens );
+		$data_store->flush_last_sent_at();
+
+		foreach ( $tokens as $token ) {
+			$this->assertNotNull( $data_store->read( $token->get_id() )->get_last_sent_at_gmt() );
+		}
+	}
+
+	/**
+	 * @testdox Tests the Action Scheduler hook flushes buffered stamps.
+	 *
+	 * The safety net and retry paths run under a queue runner that can be killed
+	 * before shutdown, so the buffer is also flushed after each action. This
+	 * exists only for a path a hook creates, so nothing else would catch its
+	 * removal.
+	 */
+	public function test_the_action_scheduler_hook_flushes_buffered_stamps() {
+		$data_store = new PushTokensDataStore();
+		$push_token = $this->create_test_push_token();
+
+		$data_store->record_last_sent_at( array( $push_token ) );
+
+		// phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment -- Firing Action Scheduler's hook, not declaring one.
+		do_action( 'action_scheduler_after_execute', 1, null, '' );
+
+		$this->assertNotNull( $data_store->read( $push_token->get_id() )->get_last_sent_at_gmt() );
+	}
+
+	/**
+	 * @testdox Tests a failed read of existing stamps does not insert duplicates.
+	 *
+	 * `wpdb::query()` returns false without setting `last_error` when the `query`
+	 * filter empties the statement, so a failed read must not be taken as "no
+	 * rows exist". `wp_postmeta` has no unique key to catch the duplicates that
+	 * would follow.
+	 */
+	public function test_a_failed_read_of_existing_stamps_does_not_insert_duplicates() {
+		global $wpdb;
+
+		$data_store = new PushTokensDataStore();
+		$push_token = $this->create_test_push_token();
+
+		$data_store->record_last_sent_at( array( $push_token ) );
+		$data_store->flush_last_sent_at();
+
+		$empty_the_select = function ( $query ) {
+			return false !== strpos( $query, 'SELECT post_id' ) && false !== strpos( $query, 'last_sent_at_gmt' )
+				? ''
+				: $query;
+		};
+
+		add_filter( 'query', $empty_the_select );
+		$data_store->record_last_sent_at( array( $push_token ) );
+		$data_store->flush_last_sent_at();
+		remove_filter( 'query', $empty_the_select );
+
+		$row_count = (int) $wpdb->get_var(
+			$wpdb->prepare(
+				"SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key = %s",
+				$push_token->get_id(),
+				PushTokensDataStore::LAST_SENT_AT_META_KEY
+			)
+		);
+
+		$this->assertSame( 1, $row_count );
+	}
+
+	/**
+	 * @testdox Tests a write that fails without throwing is reported.
+	 *
+	 * `wpdb::query()` returns false rather than throwing when the `query` filter
+	 * empties the statement, so nothing would surface the lost stamp unless the
+	 * return value is checked. The log is the only signal that stamps are not
+	 * being recorded.
+	 */
+	public function test_a_failed_write_of_stamps_is_logged() {
+		global $wpdb;
+
+		$data_store = new PushTokensDataStore();
+		$push_token = $this->create_test_push_token();
+
+		$data_store->record_last_sent_at( array( $push_token ) );
+		$data_store->flush_last_sent_at();
+
+		$empty_the_update = function ( $query ) {
+			return false !== strpos( $query, 'UPDATE' ) && false !== strpos( $query, 'last_sent_at_gmt' )
+				? ''
+				: $query;
+		};
+
+		add_filter( 'query', $empty_the_update );
+		$data_store->record_last_sent_at( array( $push_token ) );
+		$data_store->flush_last_sent_at();
+		remove_filter( 'query', $empty_the_update );
+
+		$row_count = (int) $wpdb->get_var(
+			$wpdb->prepare(
+				"SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key = %s",
+				$push_token->get_id(),
+				PushTokensDataStore::LAST_SENT_AT_META_KEY
+			)
+		);
+
+		$this->assertSame( 1, $row_count );
+		$this->assertLogged( 'warning', 'Could not record last sent time for push tokens.' );
+	}
+
+	/**
+	 * @testdox Tests an error raised while writing stamps does not escape the flush.
+	 *
+	 * The flush runs on `action_scheduler_after_execute`, which fires between an
+	 * action completing and being marked complete, so anything escaping here
+	 * records a delivered notification's action as failed. Errors do not extend
+	 * Exception, which is why the catch is on Throwable.
+	 */
+	public function test_an_error_while_writing_stamps_does_not_escape_the_flush() {
+		$data_store = new PushTokensDataStore();
+		$push_token = $this->create_test_push_token();
+
+		$raise_an_error = function ( $query ) {
+			if ( false !== strpos( $query, 'last_sent_at_gmt' ) ) {
+				throw new \Error( 'Raised for testing.' );
+			}
+
+			return $query;
+		};
+
+		add_filter( 'query', $raise_an_error );
+
+		try {
+			$data_store->record_last_sent_at( array( $push_token ) );
+			$data_store->flush_last_sent_at();
+		} finally {
+			remove_filter( 'query', $raise_an_error );
+		}
+
+		$this->assertNull( $data_store->read( $push_token->get_id() )->get_last_sent_at_gmt() );
+	}
+
+	/**
+	 * @testdox Tests recording a send with no tokens is a no-op.
+	 */
+	public function test_record_last_sent_at_ignores_an_empty_token_list() {
+		global $wpdb;
+
+		$data_store = new PushTokensDataStore();
+		$data_store->record_last_sent_at( array() );
+		$data_store->flush_last_sent_at();
+
+		$row_count = (int) $wpdb->get_var(
+			$wpdb->prepare(
+				"SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE meta_key = %s",
+				PushTokensDataStore::LAST_SENT_AT_META_KEY
+			)
+		);
+
+		$this->assertSame( 0, $row_count );
+	}
+
 	/**
 	 * Creates a test push token and saves it to the database.
 	 *
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Entities/PushTokenTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Entities/PushTokenTest.php
index 811ed08d952..c5fb2d6bbea 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Entities/PushTokenTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Entities/PushTokenTest.php
@@ -961,6 +961,26 @@ class PushTokenTest extends WC_Unit_Test_Case {
 		$this->assertNull( $push_token->get_created_at_gmt() );
 	}

+	/**
+	 * @testdox Tests an empty last sent time is never reported as the current time.
+	 *
+	 * `date_create_immutable()` rejects only genuinely unparseable strings. It
+	 * reads an empty string as the current time and the MySQL zero date as year
+	 * -0001, so the guards ahead of it are load-bearing rather than redundant.
+	 * Folding them away would make an unsent token report as sent right now,
+	 * the precise opposite of what this field is for.
+	 */
+	public function test_an_empty_last_sent_at_is_not_reported_as_now() {
+		foreach ( array( '', '0000-00-00 00:00:00', '   ' ) as $stored ) {
+			$push_token = new PushToken( array( 'last_sent_at_gmt' => $stored ) );
+
+			$this->assertNull(
+				$push_token->get_last_sent_at_gmt(),
+				sprintf( 'Expected null for stored value "%s".', $stored )
+			);
+		}
+	}
+
 	/**
 	 * @testdox Tests the REST format adds diagnostic fields without altering the WPCOM send payload.
 	 *
@@ -1015,4 +1035,17 @@ class PushTokenTest extends WC_Unit_Test_Case {
 		$this->assertNull( $rest_format['device_uuid'] );
 		$this->assertNull( $rest_format['platform'] );
 	}
+
+	/**
+	 * @testdox Tests the last sent time defaults to null and is exposed in the response.
+	 */
+	public function test_it_exposes_the_last_sent_at_time() {
+		$this->assertNull( ( new PushToken() )->get_last_sent_at_gmt() );
+
+		$push_token = new PushToken( array( 'last_sent_at_gmt' => '2026-08-11 16:00:00' ) );
+
+		$this->assertSame( '2026-08-11 16:00:00', $push_token->get_last_sent_at_gmt() );
+		$this->assertSame( '2026-08-11T16:00:00', $push_token->to_rest_format()['last_sent_at_gmt'] );
+		$this->assertArrayNotHasKey( 'last_sent_at_gmt', $push_token->to_wpcom_format() );
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationProcessorTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationProcessorTest.php
index bdb29c4cbf7..dbed4d3da71 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationProcessorTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationProcessorTest.php
@@ -164,6 +164,51 @@ class NotificationProcessorTest extends WC_Unit_Test_Case {
 		$this->assertFalse( $notification->has_meta( NotificationProcessor::TRIGGERED_META_KEY ) );
 	}

+	/**
+	 * @testdox Should record the last sent time against the dispatched tokens on success.
+	 */
+	public function test_process_records_last_sent_at_on_success(): void {
+		$this->dispatcher->method( 'dispatch' )->willReturn(
+			array(
+				'success'     => true,
+				'retry_after' => null,
+			)
+		);
+
+		$this->data_store
+			->expects( $this->once() )
+			->method( 'record_last_sent_at' )
+			->with(
+				$this->callback(
+					function ( array $tokens ) {
+						return 1 === count( $tokens ) && 'test-token' === $tokens[0]->get_token();
+					}
+				)
+			);
+
+		$this->sut->process( new NewOrderNotification( $this->order_id ) );
+	}
+
+	/**
+	 * @testdox Should not record a last sent time when the dispatch fails.
+	 *
+	 * The stamp means "WPCOM accepted a payload containing this token", so
+	 * recording it on a failed send would make a device that has never been
+	 * successfully targeted look as though it had.
+	 */
+	public function test_process_does_not_record_last_sent_at_on_failure(): void {
+		$this->dispatcher->method( 'dispatch' )->willReturn(
+			array(
+				'success'     => false,
+				'retry_after' => null,
+			)
+		);
+
+		$this->data_store->expects( $this->never() )->method( 'record_last_sent_at' );
+
+		$this->sut->process( new NewOrderNotification( $this->order_id ) );
+	}
+
 	/**
 	 * @testdox Should write claimed meta before sending.
 	 */