diff --git a/Cargo.lock b/Cargo.lock
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -878,6 +878,7 @@
  "axum 0.7.9",
  "bitcoinsuite-core 0.1.0",
  "bitcoinsuite-slp",
+ "bytes",
  "chronik-bridge",
  "chronik-db",
  "chronik-indexer",
diff --git a/chronik/chronik-http/Cargo.toml b/chronik/chronik-http/Cargo.toml
--- a/chronik/chronik-http/Cargo.toml
+++ b/chronik/chronik-http/Cargo.toml
@@ -31,6 +31,9 @@
 # HTTP webapps
 axum = { version = "0.7", features = ["ws"] }
 
+# Efficient byte strings
+bytes = "1.4"
+
 # Async toolkit
 futures = "0.3"
 
diff --git a/chronik/chronik-http/src/electrum.rs b/chronik/chronik-http/src/electrum.rs
--- a/chronik/chronik-http/src/electrum.rs
+++ b/chronik/chronik-http/src/electrum.rs
@@ -11,6 +11,9 @@
     hash::{Hashed, Sha256},
     tx::TxId,
 };
+use bytes::Bytes;
+use chronik_bridge::ffi;
+use chronik_util::log_chronik;
 use futures::future;
 use itertools::izip;
 use karyon_jsonrpc::{RPCError, RPCMethod, RPCService, Server};
@@ -307,6 +310,9 @@
 
     fn get_method(&self, name: &str) -> Option<RPCMethod<'_>> {
         match name {
+            "transaction.broadcast" => Some(Box::new(move |params: Value| {
+                Box::pin(self.transaction_broadcast(params))
+            })),
             "transaction.get" => Some(Box::new(move |params: Value| {
                 Box::pin(self.transaction_get(params))
             })),
@@ -366,6 +372,96 @@
 }
 
 impl ChronikElectrumRPCBlockchainEndpoint {
+    async fn transaction_broadcast(
+        &self,
+        params: Value,
+    ) -> Result<Value, RPCError> {
+        check_max_number_of_params!(params, 1);
+        let raw_tx = match get_param!(params, 0, "raw_tx")? {
+            Value::String(raw_tx) => Ok(raw_tx),
+            _ => Err(RPCError::CustomError(
+                1,
+                "Invalid raw_tx argument; expected hex string",
+            )),
+        }?;
+        let raw_tx = Bytes::from(hex::decode(raw_tx).map_err(|_err| {
+            RPCError::CustomError(1, "Failed to decode raw_tx as a hex string")
+        })?);
+
+        let max_fee = ffi::calc_fee(
+            raw_tx.len(),
+            ffi::default_max_raw_tx_fee_rate_per_kb(),
+        );
+        let txid = self.node.bridge.broadcast_tx(&raw_tx, max_fee);
+
+        if txid.is_err() {
+            let err = txid.err().unwrap();
+            let msg = err.what();
+            // The following error messages must be preserved so that
+            // Electrum ABC users get feedback on why their transaction
+            // failed to broadcast.
+            // See electrum/electrumabc/network.py
+            const POSSIBLE_MSG_FRAGMENTS: &[&str] = &[
+                "Transaction already in block chain",
+                "bad-txns-inputs-spent",
+                "bad-txns-inputs-missingorspent",
+                "min relay fee not met",
+                "mempool min fee not met",
+                "mempool full",
+                "bad-txns-premature-spend-of-coinbase",
+                "txn-already-in-mempool",
+                "txn-already-known",
+                "txn-mempool-conflict",
+                "bad-txns-nonstandard-inputs",
+                "Fee exceeds maximum configured by user",
+                "non-mandatory-script-verify-flag",
+                "mandatory-script-verify-flag-failed",
+                "tx-size",
+                "bad-txns-oversize",
+                "scriptsig-size",
+                "scriptpubkey",
+                "bare-multisig",
+                "multi-op-return",
+                "scriptsig-not-pushonly",
+                "bad-txns-nonfinal",
+                "non-BIP68-final",
+                "bad-txns-inputvalues-outofrange",
+                "bad-txns-vout-negative",
+                "bad-txns-vout-toolarge",
+                "bad-txns-txouttotal-toolarge",
+                "bad-txns-in-belowout",
+                "bad-txns-fee-outofrange",
+                "bad-tx-coinbase",
+                "bad-txns-prevout-null",
+                "bad-txns-inputs-duplicate",
+                "bad-txns-vin-empty",
+                "bad-txns-vout-empty",
+                "bad-txns-undersize",
+                "version",
+                "TX decode failed",
+            ];
+            for i in 0..POSSIBLE_MSG_FRAGMENTS.len() {
+                if msg.contains(POSSIBLE_MSG_FRAGMENTS[i]) {
+                    return Err(RPCError::CustomError(
+                        1,
+                        POSSIBLE_MSG_FRAGMENTS[i],
+                    ));
+                }
+            }
+            // Just return a generic error
+            log_chronik!(
+                "Encountered unexpected error message on transaction \
+                 broadcast attempt {}\n",
+                msg
+            );
+            return Err(RPCError::CustomError(1, "Failed to broadcast"));
+        }
+
+        let txid = TxId::from(txid.ok().unwrap());
+
+        Ok(Value::String(txid.to_string()))
+    }
+
     async fn transaction_get(&self, params: Value) -> Result<Value, RPCError> {
         check_max_number_of_params!(params, 2);
         let txid_hex = get_param!(params, 0, "txid")?;
diff --git a/test/functional/chronik_electrum_blockchain.py b/test/functional/chronik_electrum_blockchain.py
--- a/test/functional/chronik_electrum_blockchain.py
+++ b/test/functional/chronik_electrum_blockchain.py
@@ -11,7 +11,17 @@
     GENESIS_CB_TXID,
     TIME_GENESIS_BLOCK,
 )
+from test_framework.messages import (
+    COutPoint,
+    CTransaction,
+    CTxIn,
+    CTxOut,
+    FromHex,
+    ToHex,
+)
+from test_framework.script import OP_RETURN, OP_TRUE, CScript
 from test_framework.test_framework import BitcoinTestFramework
+from test_framework.txtools import pad_tx
 from test_framework.util import assert_equal
 from test_framework.wallet import MiniWallet
 
@@ -39,6 +49,7 @@
 
         self.test_invalid_params()
         self.test_transaction_get()
+        self.test_transaction_broadcast()
 
     def test_invalid_params(self):
         # Invalid params type
@@ -151,7 +162,7 @@
                 },
             )
 
-        self.generate(self.nodes[0], 2)
+        self.generate(self.wallet, 2)
         assert_equal(
             self.client.blockchain.transaction.get(
                 txid=GENESIS_CB_TXID, verbose=True
@@ -164,7 +175,10 @@
         assert_equal(response.result, 0)
 
         self.wallet.rescan_utxos()
-        tx = self.wallet.send_self_transfer(from_node=self.node)
+        tx = self.wallet.create_self_transfer()
+
+        response = self.client.blockchain.transaction.broadcast(tx["hex"])
+        assert_equal(response.result, tx["txid"])
 
         response = self.client.blockchain.transaction.get(tx["txid"])
         assert_equal(response.result, tx["hex"])
@@ -174,7 +188,7 @@
         assert_equal(response.result, 0)
 
         # Mine the tx
-        self.generate(self.node, 1)
+        self.generate(self.wallet, 1)
         response = self.client.blockchain.transaction.get_height(tx["txid"])
         assert_equal(response.result, 203)
 
@@ -183,6 +197,128 @@
             response.error, {"code": -32600, "message": "Unknown transaction id"}
         )
 
+    def test_transaction_broadcast(self):
+        tx = self.wallet.create_self_transfer()
+
+        for _ in range(3):
+            response = self.client.blockchain.transaction.broadcast(tx["hex"])
+            assert_equal(response.result, tx["txid"])
+
+        self.generate(self.wallet, 1)
+        response = self.client.blockchain.transaction.broadcast(tx["hex"])
+        assert_equal(
+            response.error, {"code": 1, "message": "Transaction already in block chain"}
+        )
+
+        spent_utxo = tx["tx"].vin[0]
+
+        tx_obj = self.wallet.create_self_transfer()["tx"]
+        tx_obj.vin[0] = spent_utxo
+        response = self.client.blockchain.transaction.broadcast(ToHex(tx_obj))
+        assert_equal(
+            response.error, {"code": 1, "message": "bad-txns-inputs-missingorspent"}
+        )
+
+        raw_tx_reference = self.wallet.create_self_transfer()["hex"]
+
+        tx_obj = FromHex(CTransaction(), raw_tx_reference)
+        tx_obj.vin[0].scriptSig = b"aaaaaaaaaaaaaaa"
+        response = self.client.blockchain.transaction.broadcast(ToHex(tx_obj))
+        assert_equal(response.error, {"code": 1, "message": "scriptsig-not-pushonly"})
+
+        tx_obj = FromHex(CTransaction(), raw_tx_reference)
+        tx_obj.vout[0].scriptPubKey = CScript([OP_RETURN, b"\xff"])
+        tx_obj.vout = [tx_obj.vout[0]] * 2
+        response = self.client.blockchain.transaction.broadcast(ToHex(tx_obj))
+        assert_equal(response.error, {"code": 1, "message": "multi-op-return"})
+
+        tx_obj = FromHex(CTransaction(), raw_tx_reference)
+        tx_obj.vin[0].nSequence = 0xFFFFFFFE
+        tx_obj.nLockTime = self.node.getblockcount() + 1
+        response = self.client.blockchain.transaction.broadcast(ToHex(tx_obj))
+        assert_equal(response.error, {"code": 1, "message": "bad-txns-nonfinal"})
+
+        tx_obj = FromHex(CTransaction(), raw_tx_reference)
+        tx_obj.vout = []
+        response = self.client.blockchain.transaction.broadcast(ToHex(tx_obj))
+        assert_equal(response.error, {"code": 1, "message": "bad-txns-vout-empty"})
+
+        # Non-standard script
+        tx_obj.vout.append(CTxOut(0, CScript([OP_TRUE])))
+        response = self.client.blockchain.transaction.broadcast(ToHex(tx_obj))
+        assert_equal(response.error, {"code": 1, "message": "scriptpubkey"})
+
+        tx_obj.vout[0] = CTxOut(0, CScript([OP_RETURN, b"\xff"]))
+        assert len(ToHex(tx_obj)) // 2 < 100
+        response = self.client.blockchain.transaction.broadcast(ToHex(tx_obj))
+        assert_equal(response.error, {"code": 1, "message": "bad-txns-undersize"})
+
+        tx_obj = self.wallet.create_self_transfer()["tx"]
+        pad_tx(tx_obj, 100_001)
+        response = self.client.blockchain.transaction.broadcast(ToHex(tx_obj))
+        assert_equal(response.error, {"code": 1, "message": "tx-size"})
+
+        tx_obj = FromHex(CTransaction(), raw_tx_reference)
+        tx_obj.vin = []
+        response = self.client.blockchain.transaction.broadcast(ToHex(tx_obj))
+        assert_equal(response.error, {"code": 1, "message": "bad-txns-vin-empty"})
+
+        tx_obj = FromHex(CTransaction(), raw_tx_reference)
+        tx_obj.vin.append(tx_obj.vin[0])
+        response = self.client.blockchain.transaction.broadcast(ToHex(tx_obj))
+        assert_equal(
+            response.error, {"code": 1, "message": "bad-txns-inputs-duplicate"}
+        )
+
+        tx_obj = FromHex(CTransaction(), raw_tx_reference)
+        tx_obj.nVersion = 1337
+        response = self.client.blockchain.transaction.broadcast(ToHex(tx_obj))
+        assert_equal(response.error, {"code": 1, "message": "version"})
+
+        # Coinbase input in first position
+        tx_obj = FromHex(CTransaction(), raw_tx_reference)
+        tx_obj.vin[0] = CTxIn(COutPoint(txid=0, n=0xFFFFFFFF))
+        response = self.client.blockchain.transaction.broadcast(ToHex(tx_obj))
+        assert_equal(response.error, {"code": 1, "message": "bad-tx-coinbase"})
+
+        # Coinbase input in second position
+        tx_obj = FromHex(CTransaction(), raw_tx_reference)
+        tx_obj.vin.append(CTxIn(COutPoint(txid=0, n=0xFFFFFFFF)))
+        response = self.client.blockchain.transaction.broadcast(ToHex(tx_obj))
+        assert_equal(response.error, {"code": 1, "message": "bad-txns-prevout-null"})
+
+        tx = self.wallet.create_self_transfer(fee_rate=0, fee=0)
+        response = self.client.blockchain.transaction.broadcast(tx["hex"])
+        assert_equal(response.error, {"code": 1, "message": "min relay fee not met"})
+
+        tx = self.wallet.create_self_transfer(fee_rate=10_000_000, fee=0)
+        response = self.client.blockchain.transaction.broadcast(tx["hex"])
+        assert_equal(
+            response.error,
+            {"code": 1, "message": "Fee exceeds maximum configured by user"},
+        )
+
+        # Mine a comfortable number of blocks to ensure that the following test does
+        # not try to spend a utxo already spent in a previous test.
+        # Invalidate two blocks, so that miniwallet has access to a coin that
+        # will mature in the next block.
+        self.generate(self.wallet, 100)
+        chain_height = self.node.getblockcount() - 3
+        block_to_invalidate = self.node.getblockhash(chain_height + 1)
+        self.node.invalidateblock(block_to_invalidate)
+        immature_txid = self.nodes[0].getblock(
+            self.nodes[0].getblockhash(chain_height - 100 + 2)
+        )["tx"][0]
+        immature_utxo = self.wallet.get_utxo(txid=immature_txid)
+        tx = self.wallet.create_self_transfer(utxo_to_spend=immature_utxo)
+        response = self.client.blockchain.transaction.broadcast(tx["hex"])
+        assert_equal(
+            response.error,
+            {"code": 1, "message": "bad-txns-premature-spend-of-coinbase"},
+        )
+
+        self.node.reconsiderblock(block_to_invalidate)
+
 
 if __name__ == "__main__":
     ChronikElectrumBlockchain().main()