diff --git a/chronik/chronik-db/src/group.rs b/chronik/chronik-db/src/group.rs
--- a/chronik/chronik-db/src/group.rs
+++ b/chronik/chronik-db/src/group.rs
@@ -4,7 +4,10 @@
 
 //! Module for [`Group`] and [`GroupQuery`].
 
-use bitcoinsuite_core::tx::{Tx, TxOutput};
+use bitcoinsuite_core::{
+    hash::Sha256,
+    tx::{Tx, TxOutput},
+};
 use bytes::Bytes;
 use serde::{Deserialize, Serialize};
 
@@ -29,6 +32,25 @@
     pub member: M,
 }
 
+/// Various ways of querying a the db for a group member
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum GroupMember<M> {
+    /// By member
+    Member(M),
+    /// By member hash
+    MemberHash(Sha256),
+}
+
+impl<M> GroupMember<M> {
+    /// Converts from &GroupMember<M> to GroupMember<&M>.
+    pub fn as_ref(&self) -> GroupMember<&M> {
+        match self {
+            GroupMember::Member(member) => GroupMember::Member(member),
+            GroupMember::MemberHash(hash) => GroupMember::MemberHash(*hash),
+        }
+    }
+}
+
 /// Groups txs and determines which members they are.
 ///
 /// A member is one instance in a group and can be anything in a tx, e.g. the
diff --git a/chronik/chronik-db/src/io/group_history.rs b/chronik/chronik-db/src/io/group_history.rs
--- a/chronik/chronik-db/src/io/group_history.rs
+++ b/chronik/chronik-db/src/io/group_history.rs
@@ -10,12 +10,14 @@
 use rocksdb::WriteBatch;
 use thiserror::Error;
 
+use crate::ser::db_deserialize;
 use crate::{
-    db::{Db, CF},
+    db::{Db, CF, CF_META},
     group::{tx_members_for_group, Group, GroupQuery},
     index_tx::IndexTx,
     io::{
-        group_history::GroupHistoryError::*, merge::catch_merge_errors, TxNum,
+        group_history::GroupHistoryError::*, merge::catch_merge_errors,
+        metadata::FIELD_SCRIPTHASH_INDEX_ENABLED, TxNum,
     },
     ser::{db_deserialize_vec, db_serialize_vec},
 };
@@ -126,6 +128,10 @@
         hex::encode(.1),
     )]
     UnknownOperandPrefix(u8, Vec<u8>),
+
+    /// Tried to access member_hash column family via a non-script group.
+    #[error("Tried to access member hash db via non-script group")]
+    GroupDoesNotImplementMemberHash,
 }
 
 struct FetchedNumTxs<'tx, G: Group> {
@@ -213,6 +219,26 @@
         };
         Ok(Some(db_deserialize_vec::<TxNum>(&value)?))
     }
+
+    fn is_member_hash_index_enabled(&self) -> Result<bool> {
+        let meta_cf = self.db.cf(CF_META)?;
+        match self.db.get(meta_cf, FIELD_SCRIPTHASH_INDEX_ENABLED)? {
+            Some(ser_flag) => Ok(db_deserialize::<bool>(&ser_flag)?),
+            None => Ok(false),
+        }
+    }
+
+    fn get_member_ser_by_member_hash(
+        &self,
+        member_hash: Sha256,
+    ) -> Result<Option<Vec<u8>>> {
+        let cf_member_hash =
+            self.cf_member_hash.ok_or(GroupDoesNotImplementMemberHash)?;
+        match self.db.get(cf_member_hash, member_hash.to_be_bytes())? {
+            Some(value) => Ok(Some(value.to_vec())),
+            None => Ok(None),
+        }
+    }
 }
 
 impl<'a, G: Group> GroupHistoryWriter<'a, G> {
@@ -574,6 +600,21 @@
     pub fn page_size(&self) -> usize {
         self.conf.page_size as usize
     }
+
+    /// Return whether the scripthash index is enabled (according to the
+    /// metadata db).
+    pub fn is_member_hash_index_enabled(&self) -> Result<bool> {
+        self.col.is_member_hash_index_enabled()
+    }
+
+    /// Serialized member from member_hash (only implemented for
+    /// ScriptHistoryReader)
+    pub fn member_ser_by_member_hash(
+        &self,
+        member_hash: Sha256,
+    ) -> Result<Option<Vec<u8>>> {
+        self.col.get_member_ser_by_member_hash(member_hash)
+    }
 }
 
 pub(crate) fn key_for_member_page(
diff --git a/chronik/chronik-http/src/handlers.rs b/chronik/chronik-http/src/handlers.rs
--- a/chronik/chronik-http/src/handlers.rs
+++ b/chronik/chronik-http/src/handlers.rs
@@ -3,8 +3,10 @@
 use std::{collections::HashMap, fmt::Display, str::FromStr};
 
 use abc_rust_error::{Report, Result};
+use bitcoinsuite_core::hash::{Hashed, Sha256};
+use bitcoinsuite_core::script::Script;
 use bitcoinsuite_slp::token_id::TokenId;
-use chronik_db::plugins::PluginMember;
+use chronik_db::{group::GroupMember, plugins::PluginMember};
 use chronik_indexer::indexer::{ChronikIndexer, Node};
 use chronik_plugin::data::{PluginIdx, PluginNameMap};
 use chronik_proto::proto;
@@ -37,6 +39,10 @@
     /// Plugin with the given name not loaded.
     #[error("404: Plugin {0:?} not loaded")]
     PluginNotLoaded(String),
+
+    /// Could not parse script hash.
+    #[error("400: Unable to parse script hash {0:?}")]
+    InvalidScriptHash(String),
 }
 
 use self::ChronikHandlerError::*;
@@ -85,6 +91,21 @@
     blocks.block_txs(hash_or_height, page_num as usize, page_size as usize)
 }
 
+fn get_group_member(
+    script_type: &str,
+    payload: &str,
+) -> Result<GroupMember<Script>> {
+    if script_type == "scripthash" {
+        let script_hash = Sha256::from_be_hex(payload)
+            .map_err(|_| InvalidScriptHash(payload.to_string()))?;
+        Ok(GroupMember::MemberHash(script_hash))
+    } else {
+        let script =
+            parse_script_variant_hex(script_type, payload)?.to_script();
+        Ok(GroupMember::Member(script))
+    }
+}
+
 /// Return a page of the confirmed txs of the given script.
 /// Scripts are identified by script_type and payload.
 pub async fn handle_script_confirmed_txs(
@@ -94,12 +115,15 @@
     indexer: &ChronikIndexer,
     node: &Node,
 ) -> Result<proto::TxHistoryPage> {
-    let script_variant = parse_script_variant_hex(script_type, payload)?;
     let script_history = indexer.script_history(node)?;
     let page_num: u32 = get_param(query_params, "page")?.unwrap_or(0);
     let page_size: u32 = get_param(query_params, "page_size")?.unwrap_or(25);
-    let script = script_variant.to_script();
-    script_history.confirmed_txs(&script, page_num as usize, page_size as usize)
+    let member = get_group_member(script_type, payload)?;
+    script_history.confirmed_txs(
+        member.as_ref(),
+        page_num as usize,
+        page_size as usize,
+    )
 }
 
 /// Return a page of the tx history of the given script, in reverse
@@ -112,12 +136,15 @@
     indexer: &ChronikIndexer,
     node: &Node,
 ) -> Result<proto::TxHistoryPage> {
-    let script_variant = parse_script_variant_hex(script_type, payload)?;
     let script_history = indexer.script_history(node)?;
     let page_num: u32 = get_param(query_params, "page")?.unwrap_or(0);
     let page_size: u32 = get_param(query_params, "page_size")?.unwrap_or(25);
-    let script = script_variant.to_script();
-    script_history.rev_history(&script, page_num as usize, page_size as usize)
+    let member = get_group_member(script_type, payload)?;
+    script_history.rev_history(
+        member.as_ref(),
+        page_num as usize,
+        page_size as usize,
+    )
 }
 
 /// Return a page of the unconfirmed txs of the given script.
@@ -128,10 +155,9 @@
     indexer: &ChronikIndexer,
     node: &Node,
 ) -> Result<proto::TxHistoryPage> {
-    let script_variant = parse_script_variant_hex(script_type, payload)?;
     let script_history = indexer.script_history(node)?;
-    let script = script_variant.to_script();
-    script_history.unconfirmed_txs(&script)
+    let member = get_group_member(script_type, payload)?;
+    script_history.unconfirmed_txs(member.as_ref())
 }
 
 /// Return the UTXOs of the given script.
@@ -163,7 +189,7 @@
     let page_num: u32 = get_param(query_params, "page")?.unwrap_or(0);
     let page_size: u32 = get_param(query_params, "page_size")?.unwrap_or(25);
     token_id_history.confirmed_txs(
-        token_id,
+        GroupMember::Member(token_id),
         page_num as usize,
         page_size as usize,
     )
@@ -183,7 +209,7 @@
     let page_num: u32 = get_param(query_params, "page")?.unwrap_or(0);
     let page_size: u32 = get_param(query_params, "page_size")?.unwrap_or(25);
     token_id_history.rev_history(
-        token_id,
+        GroupMember::Member(token_id),
         page_num as usize,
         page_size as usize,
     )
@@ -197,7 +223,7 @@
 ) -> Result<proto::TxHistoryPage> {
     let token_id = token_id_hex.parse::<TokenId>()?;
     let token_id_history = indexer.token_id_history(node);
-    token_id_history.unconfirmed_txs(token_id)
+    token_id_history.unconfirmed_txs(GroupMember::Member(token_id))
 }
 
 /// Return the UTXOs of the given token ID.
@@ -223,7 +249,7 @@
     let page_num: u32 = get_param(query_params, "page")?.unwrap_or(0);
     let page_size: u32 = get_param(query_params, "page_size")?.unwrap_or(25);
     lokad_id_history.confirmed_txs(
-        lokad_id,
+        GroupMember::Member(lokad_id),
         page_num as usize,
         page_size as usize,
     )
@@ -243,7 +269,7 @@
     let page_num: u32 = get_param(query_params, "page")?.unwrap_or(0);
     let page_size: u32 = get_param(query_params, "page_size")?.unwrap_or(25);
     lokad_id_history.rev_history(
-        lokad_id,
+        GroupMember::Member(lokad_id),
         page_num as usize,
         page_size as usize,
     )
@@ -257,7 +283,7 @@
 ) -> Result<proto::TxHistoryPage> {
     let lokad_id = parse_lokad_id_hex(lokad_id_hex)?;
     let lokad_id_history = indexer.lokad_id_history(node);
-    lokad_id_history.unconfirmed_txs(lokad_id)
+    lokad_id_history.unconfirmed_txs(GroupMember::Member(lokad_id))
 }
 
 /// Return the UTXOs of the given plugin and group.
@@ -293,7 +319,7 @@
         group: &group,
     };
     plugin_history.confirmed_txs(
-        member.ser(),
+        GroupMember::Member(member.ser()),
         page_num as usize,
         page_size as usize,
     )
@@ -313,7 +339,7 @@
         plugin_idx,
         group: &group,
     };
-    plugin_history.unconfirmed_txs(member.ser())
+    plugin_history.unconfirmed_txs(GroupMember::Member(member.ser()))
 }
 
 /// Return a page of the tx history of the given group of the given plugin, in
@@ -336,7 +362,7 @@
         group: &group,
     };
     plugin_history.rev_history(
-        member.ser(),
+        GroupMember::Member(member.ser()),
         page_num as usize,
         page_size as usize,
     )
diff --git a/chronik/chronik-indexer/src/query/group_history.rs b/chronik/chronik-indexer/src/query/group_history.rs
--- a/chronik/chronik-indexer/src/query/group_history.rs
+++ b/chronik/chronik-indexer/src/query/group_history.rs
@@ -7,10 +7,14 @@
 use std::collections::BTreeSet;
 
 use abc_rust_error::Result;
-use bitcoinsuite_core::tx::{Tx, TxId};
+use bitcoinsuite_core::{
+    hash::Hashed,
+    tx::{Tx, TxId},
+};
+use bytes::Bytes;
 use chronik_db::{
     db::Db,
-    group::Group,
+    group::{Group, GroupMember},
     io::{BlockReader, GroupHistoryReader, SpentByReader, TxNum, TxReader},
     mem::{Mempool, MempoolGroupHistory},
 };
@@ -84,18 +88,56 @@
         MIN_HISTORY_PAGE_SIZE
     )]
     RequestPageSizeTooSmall(usize),
+
+    /// Script hash not found
+    #[error("404: Script hash {0:?} not found")]
+    ScriptHashNotFound(String),
+
+    /// Script hash index not enabled
+    #[error("400: Script hash index disabled")]
+    ScriptHashIndexDisabled,
 }
 
 use self::QueryGroupHistoryError::*;
 
 impl<'a, G: Group> QueryGroupHistory<'a, G> {
+    fn member_ser_from_member(
+        &self,
+        member: &GroupMember<G::Member<'_>>,
+        db_reader: &GroupHistoryReader<'_, G>,
+    ) -> Result<Bytes> {
+        match member {
+            GroupMember::Member(member) => Ok(Bytes::copy_from_slice(
+                self.group.ser_member(member).as_ref(),
+            )),
+            GroupMember::MemberHash(memberhash) => {
+                let script_ser =
+                    db_reader.member_ser_by_member_hash(*memberhash)?;
+                if script_ser.is_none() {
+                    if db_reader.is_member_hash_index_enabled()? {
+                        return Err(
+                            ScriptHashNotFound(memberhash.hex_be()).into()
+                        );
+                    } else {
+                        return Err(ScriptHashIndexDisabled.into());
+                    }
+                }
+                Ok(Bytes::from(
+                    script_ser.ok_or_else(|| {
+                        ScriptHashNotFound(memberhash.hex_be())
+                    })?,
+                ))
+            }
+        }
+    }
+
     /// Return the confirmed txs of the group in the order as txs occur on the
     /// blockchain, i.e.:
     /// - Sorted by block height ascendingly.
     /// - Within a block, sorted as txs occur in the block.
     pub fn confirmed_txs(
         &self,
-        member: G::Member<'_>,
+        member: GroupMember<G::Member<'_>>,
         request_page_num: usize,
         request_page_size: usize,
     ) -> Result<proto::TxHistoryPage> {
@@ -106,8 +148,7 @@
             return Err(RequestPageSizeTooBig(request_page_size).into());
         }
         let db_reader = GroupHistoryReader::<G>::new(self.db)?;
-        let member_ser = self.group.ser_member(&member);
-
+        let member_ser = self.member_ser_from_member(&member, &db_reader)?;
         let (num_db_pages, num_db_txs) =
             db_reader.member_num_pages_and_txs(member_ser.as_ref())?;
         let num_request_pages =
@@ -195,7 +236,7 @@
     /// are mostly interested in the "latest" txs of the address.
     pub fn rev_history(
         &self,
-        member: G::Member<'_>,
+        member: GroupMember<G::Member<'_>>,
         request_page_num: usize,
         request_page_size: usize,
     ) -> Result<proto::TxHistoryPage> {
@@ -207,7 +248,7 @@
         }
 
         let db_reader = GroupHistoryReader::<G>::new(self.db)?;
-        let member_ser = self.group.ser_member(&member);
+        let member_ser = self.member_ser_from_member(&member, &db_reader)?;
         let (_, num_db_txs) =
             db_reader.member_num_pages_and_txs(member_ser.as_ref())?;
 
@@ -355,9 +396,10 @@
     /// pagination later.
     pub fn unconfirmed_txs(
         &self,
-        member: G::Member<'_>,
+        member: GroupMember<G::Member<'_>>,
     ) -> Result<proto::TxHistoryPage> {
-        let member_ser = self.group.ser_member(&member);
+        let db_reader = GroupHistoryReader::<G>::new(self.db)?;
+        let member_ser = self.member_ser_from_member(&member, &db_reader)?;
         let txs = match self.mempool_history.member_history(member_ser.as_ref())
         {
             Some(mempool_txs) => mempool_txs
diff --git a/test/functional/chronik_scripthash.py b/test/functional/chronik_scripthash.py
new file mode 100644
--- /dev/null
+++ b/test/functional/chronik_scripthash.py
@@ -0,0 +1,194 @@
+# Copyright (c) 2024 The Bitcoin developers
+# Distributed under the MIT software license, see the accompanying
+# file COPYING or http://www.opensource.org/licenses/mit-license.php.
+"""
+Test Chronik's /script/scripthash/:payload/* endpoints.
+"""
+
+from test_framework.address import (
+    ADDRESS_ECREG_P2SH_OP_TRUE,
+    ADDRESS_ECREG_UNSPENDABLE,
+    P2SH_OP_TRUE,
+)
+from test_framework.blocktools import GENESIS_CB_PK, GENESIS_CB_SCRIPT_PUBKEY
+from test_framework.hash import hex_be_sha256
+from test_framework.test_framework import BitcoinTestFramework
+from test_framework.util import assert_equal
+from test_framework.wallet import MiniWallet, MiniWalletMode
+
+GENESIS_CB_SCRIPTHASH = hex_be_sha256(GENESIS_CB_SCRIPT_PUBKEY)
+
+
+class ChronikScriptHashTest(BitcoinTestFramework):
+    def set_test_params(self):
+        self.setup_clean_chain = True
+        self.num_nodes = 1
+        self.extra_args = [["-chronik", "-chronikscripthashindex=1"]]
+        self.rpc_timeout = 240
+
+    def skip_test_if_missing_module(self):
+        self.skip_if_no_chronik()
+
+    def run_test(self):
+        self.node = self.nodes[0]
+        self.chronik = self.node.get_chronik_client()
+        self.wallet = MiniWallet(self.node, mode=MiniWalletMode.ADDRESS_OP_TRUE)
+
+        self.test_invalid_requests()
+        self.test_valid_requests()
+        self.test_wipe_index()
+
+    def test_invalid_requests(self):
+        for payload in ("lorem_ipsum", "", "deadbeef", "deadbee", 31 * "ff", 33 * "ff"):
+            err_msg = f'400: Unable to parse script hash "{payload}"'
+            assert_equal(
+                self.chronik.script("scripthash", payload).confirmed_txs().err(400).msg,
+                err_msg,
+            )
+            assert_equal(
+                self.chronik.script("scripthash", payload).history().err(400).msg,
+                err_msg,
+            )
+            assert_equal(
+                self.chronik.script("scripthash", payload)
+                .unconfirmed_txs()
+                .err(400)
+                .msg,
+                err_msg,
+            )
+
+        # Potentially valid sha256 hash, but unlikely to collide with any existing
+        # scripthash
+        valid_payload = 32 * "ff"
+        err_msg = f'404: Script hash "{valid_payload}" not found'
+        assert_equal(
+            self.chronik.script("scripthash", valid_payload)
+            .confirmed_txs()
+            .err(404)
+            .msg,
+            err_msg,
+        )
+        assert_equal(
+            self.chronik.script("scripthash", valid_payload)
+            .confirmed_txs()
+            .err(404)
+            .msg,
+            err_msg,
+        )
+        assert_equal(
+            self.chronik.script("scripthash", valid_payload)
+            .confirmed_txs()
+            .err(404)
+            .msg,
+            err_msg,
+        )
+
+    def test_valid_requests(self):
+        from test_framework.chronik.client import pb
+        from test_framework.chronik.test_data import genesis_cb_tx
+
+        expected_cb_history = pb.TxHistoryPage(
+            txs=[genesis_cb_tx()], num_pages=1, num_txs=1
+        )
+        assert_equal(
+            self.chronik.script("scripthash", GENESIS_CB_SCRIPTHASH)
+            .confirmed_txs()
+            .ok(),
+            expected_cb_history,
+        )
+        assert_equal(
+            self.chronik.script("scripthash", GENESIS_CB_SCRIPTHASH).history().ok(),
+            expected_cb_history,
+        )
+        # No txs in mempool for the genesis pubkey
+        assert_equal(
+            self.chronik.script("scripthash", GENESIS_CB_SCRIPTHASH)
+            .unconfirmed_txs()
+            .ok(),
+            pb.TxHistoryPage(num_pages=0, num_txs=0),
+        )
+
+        scripthash_payload_hex = hex_be_sha256(P2SH_OP_TRUE)
+
+        def check_num_txs(num_block_txs, num_mempool_txs):
+            page_size = 200
+            page_num = 0
+            script_conf_txs = (
+                self.chronik.script("scripthash", scripthash_payload_hex)
+                .confirmed_txs(page_num, page_size)
+                .ok()
+            )
+            assert_equal(script_conf_txs.num_txs, num_block_txs)
+            script_history = (
+                self.chronik.script("scripthash", scripthash_payload_hex)
+                .history(page_num, page_size)
+                .ok()
+            )
+            assert_equal(script_history.num_txs, num_block_txs + num_mempool_txs)
+            script_unconf_txs = (
+                self.chronik.script("scripthash", scripthash_payload_hex)
+                .unconfirmed_txs()
+                .ok()
+            )
+            assert_equal(script_unconf_txs.num_txs, num_mempool_txs)
+
+        # Generate blocks to some address and verify the history
+        blockhashes = self.generatetoaddress(self.node, 10, ADDRESS_ECREG_P2SH_OP_TRUE)
+        check_num_txs(num_block_txs=len(blockhashes), num_mempool_txs=0)
+
+        # Undo last block & check history
+        self.node.invalidateblock(blockhashes[-1])
+        check_num_txs(num_block_txs=len(blockhashes) - 1, num_mempool_txs=0)
+
+        # Create a replacement block (use a different destination address to ensure it
+        # has a hash different from the invalidated one)
+        blockhashes[-1] = self.generatetoaddress(
+            self.node, 1, ADDRESS_ECREG_UNSPENDABLE
+        )[0]
+
+        # Mature 10 coinbase outputs
+        blockhashes += self.generatetoaddress(
+            self.node, 101, ADDRESS_ECREG_P2SH_OP_TRUE
+        )
+        check_num_txs(num_block_txs=len(blockhashes) - 1, num_mempool_txs=0)
+
+        # Add mempool txs
+        self.wallet.rescan_utxos()
+        num_mempool_txs = 0
+        for _ in range(10):
+            self.wallet.send_self_transfer(from_node=self.node)
+            num_mempool_txs += 1
+            check_num_txs(
+                num_block_txs=len(blockhashes) - 1, num_mempool_txs=num_mempool_txs
+            )
+
+        # Mine mempool txs, now they're in confirmed-txs
+        blockhashes += self.generatetoaddress(self.node, 1, ADDRESS_ECREG_P2SH_OP_TRUE)
+        check_num_txs(
+            num_block_txs=len(blockhashes) + num_mempool_txs - 1, num_mempool_txs=0
+        )
+
+    def test_wipe_index(self):
+        self.log.info("Restarting with chronikscripthashindex=0 wipes the index")
+        self.restart_node(0, ["-chronik", "-chronikscripthashindex=0"])
+        # TODO improve the error message in this case to tell that the index is disabled
+        assert_equal(
+            self.chronik.script("scripthash", GENESIS_CB_SCRIPTHASH)
+            .confirmed_txs()
+            .err(400)
+            .msg,
+            "400: Script hash index disabled",
+        )
+
+        self.log.info("Restarting with chronikscripthashindex=1 restores the index")
+        self.restart_node(0, ["-chronik", "-chronikscripthashindex=1"])
+        assert_equal(
+            self.chronik.script("p2pk", GENESIS_CB_PK).confirmed_txs().ok(),
+            self.chronik.script("scripthash", GENESIS_CB_SCRIPTHASH)
+            .confirmed_txs()
+            .ok(),
+        )
+
+
+if __name__ == "__main__":
+    ChronikScriptHashTest().main()
diff --git a/test/functional/test_framework/hash.py b/test/functional/test_framework/hash.py
--- a/test/functional/test_framework/hash.py
+++ b/test/functional/test_framework/hash.py
@@ -11,3 +11,7 @@
 
 def hash160(s: bytes) -> bytes:
     return ripemd160(sha256(s))
+
+
+def hex_be_sha256(data: bytes) -> str:
+    return sha256(data)[::-1].hex()