diff --git a/Makefile.am b/Makefile.am
index 69b66787..fe6a1b9e 100644
--- a/Makefile.am
+++ b/Makefile.am
@@ -3,7 +3,7 @@
 #
 
 SUBDIRS = snapper examples dbus server client scripts pam data doc po		\
-	testsuite testsuite-real testsuite-cmp zypp-plugin
+	testsuite testsuite-real testsuite-cmp stomp zypp-plugin
 
 AUTOMAKE_OPTIONS = foreign dist-bzip2 no-dist-gzip
 
diff --git a/configure.ac b/configure.ac
index b5fe5f4d..53a095f8 100644
--- a/configure.ac
+++ b/configure.ac
@@ -224,6 +224,8 @@ AC_CONFIG_FILES([
 	testsuite/Makefile
 	testsuite-real/Makefile
 	testsuite-cmp/Makefile
+	stomp/Makefile
+	stomp/testsuite/Makefile
 	zypp-plugin/Makefile
 	zypp-plugin/testsuite/Makefile
 	package/snapper.spec:snapper.spec.in
diff --git a/stomp/.gitignore b/stomp/.gitignore
new file mode 100644
index 00000000..66a3f3fe
--- /dev/null
+++ b/stomp/.gitignore
@@ -0,0 +1,2 @@
+*.lo
+*.la
diff --git a/stomp/Makefile.am b/stomp/Makefile.am
new file mode 100644
index 00000000..ef425daf
--- /dev/null
+++ b/stomp/Makefile.am
@@ -0,0 +1,10 @@
+#
+# Makefile.am for snapper/stomp
+#
+
+SUBDIRS = . testsuite
+
+noinst_LTLIBRARIES = libstomp.la
+
+libstomp_la_SOURCES = 	\
+	Stomp.h 	Stomp.cc
diff --git a/stomp/Stomp.cc b/stomp/Stomp.cc
new file mode 100644
index 00000000..ebdd2af9
--- /dev/null
+++ b/stomp/Stomp.cc
@@ -0,0 +1,216 @@
+/*
+ * Copyright (c) [2019-2024] SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of version 2 of the GNU General Public License as published
+ * by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, contact SUSE LLC.
+ *
+ * To contact SUSE about this file by physical or electronic mail, you may
+ * find current contact information at www.suse.com.
+ */
+
+
+#include <iostream>
+#include <regex>
+
+
+using namespace std;
+
+
+#include "Stomp.h"
+
+
+namespace Stomp
+{
+
+    Message
+    read_message(istream& is)
+    {
+	static const regex rx_command("[A-Za-z0-9_]+", regex::extended);
+
+	enum class State { Start, Headers, Body } state = State::Start;
+	bool has_content_length = false;
+	ssize_t content_length = 0;
+
+	Message msg;
+
+	while (!is.eof())
+	{
+	    string line;
+	    getline(is, line);
+
+	    if (state == State::Start)
+	    {
+		if (is.eof())
+		    return msg; // empty
+
+		if (line.empty())
+		    continue;
+
+		if (regex_match(line, rx_command))
+		{
+		    msg = Message();
+		    msg.command = line;
+		    state = State::Headers;
+		}
+		else
+		{
+		    throw runtime_error("stomp error: expected a command, got '" + line + "'");
+		}
+	    }
+	    else if (state == State::Headers)
+	    {
+		if (line.empty())
+		{
+		    state = State::Body;
+
+		    if (has_content_length)
+		    {
+			if (content_length > 0)
+			{
+			    vector<char> buf(content_length);
+			    is.read(buf.data(), content_length);
+			    msg.body.assign(buf.data(), content_length);
+			}
+
+			// still read the \0 that terminates the frame
+			char buf2 = '-';
+			is.read(&buf2, 1);
+			if (buf2 != '\0')
+			    throw runtime_error("stomp error: missing \\0 at frame end");
+		    }
+		    else
+		    {
+			getline(is, msg.body, '\0');
+		    }
+
+		    return msg;
+		}
+		else
+		{
+		    string::size_type pos = line.find(':');
+		    if (pos == string::npos)
+			throw runtime_error("stomp error: expected a header or new line, got '" + line + "'");
+
+		    string key = unescape_header(line.substr(0, pos));
+		    string value = unescape_header(line.substr(pos + 1));
+
+		    if (key == "content-length")
+		    {
+			has_content_length = true;
+			content_length = std::stol(value.c_str());
+		    }
+
+		    msg.headers[key] = value;
+		}
+	    }
+	}
+
+	throw runtime_error("stomp error: expected a message, got a part of it");
+    }
+
+
+    void
+    write_message(ostream& os, const Message& msg)
+    {
+	os << msg.command << '\n';
+	for (auto it : msg.headers)
+	    os << escape_header(it.first) << ':' << escape_header(it.second) << '\n';
+	os << '\n';
+	os << msg.body << '\0';
+	os.flush();
+    }
+
+
+    Message
+    ack()
+    {
+	Message msg;
+	msg.command = "ACK";
+	return msg;
+    }
+
+
+    Message
+    nack()
+    {
+	Message msg;
+	msg.command = "NACK";
+	return msg;
+    }
+
+
+    std::string
+    escape_header(const std::string& in)
+    {
+	string out;
+
+	for (const char c : in)
+	{
+	    switch (c)
+	    {
+		case '\r':
+		    out += "\\r"; break;
+		case '\n':
+		    out += "\\n"; break;
+		case ':':
+		    out += "\\c"; break;
+		case '\\':
+		    out += "\\\\"; break;
+
+		default:
+		    out += c;
+	    }
+	}
+
+	return out;
+    }
+
+
+    std::string
+    unescape_header(const std::string& in)
+    {
+	string out;
+
+	for (string::const_iterator it = in.begin(); it != in.end(); ++it)
+	{
+	    if (*it == '\\')
+	    {
+		if (++it == in.end())
+		    throw runtime_error("stomp error: invalid start of escape sequence");
+
+		switch (*it)
+		{
+		    case 'r':
+			out += '\r'; break;
+		    case 'n':
+			out += '\n'; break;
+		    case 'c':
+			out += ':'; break;
+		    case '\\':
+			out += '\\'; break;
+
+		    default:
+			throw runtime_error("stomp error: unknown escape sequence");
+		}
+	    }
+	    else
+	    {
+		out += *it;
+	    }
+	}
+
+	return out;
+    }
+
+}
diff --git a/stomp/Stomp.h b/stomp/Stomp.h
new file mode 100644
index 00000000..2220d8dc
--- /dev/null
+++ b/stomp/Stomp.h
@@ -0,0 +1,60 @@
+/*
+ * Copyright (c) [2019-2024] SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of version 2 of the GNU General Public License as published
+ * by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, contact SUSE LLC.
+ *
+ * To contact SUSE about this file by physical or electronic mail, you may
+ * find current contact information at www.suse.com.
+ */
+
+
+#ifndef SNAPPER_STOMP_H
+#define SNAPPER_STOMP_H
+
+
+#include <istream>
+#include <ostream>
+#include <string>
+#include <map>
+
+
+/**
+ * A tiny STOMP (https://stomp.github.io/) implementation.
+ */
+
+namespace Stomp
+{
+
+    struct Message
+    {
+	std::string command;
+	std::map<std::string, std::string> headers;
+	std::string body;
+    };
+
+
+    Message read_message(std::istream& is);
+    void write_message(std::ostream& os, const Message& msg);
+
+    Message ack();
+    Message nack();
+
+    std::string escape_header(const std::string& in);
+    std::string unescape_header(const std::string& in);
+
+}
+
+
+#endif
diff --git a/stomp/testsuite/.gitignore b/stomp/testsuite/.gitignore
new file mode 100644
index 00000000..85f0d0bb
--- /dev/null
+++ b/stomp/testsuite/.gitignore
@@ -0,0 +1,5 @@
+*.log
+*.o
+*.test
+*.trs
+test-suite.log
diff --git a/stomp/testsuite/Makefile.am b/stomp/testsuite/Makefile.am
new file mode 100644
index 00000000..424ce182
--- /dev/null
+++ b/stomp/testsuite/Makefile.am
@@ -0,0 +1,17 @@
+#
+# Makefile.am for snapper/stomp/testsuite
+#
+
+SUBDIRS = .
+
+LDADD =								\
+	../libstomp.la						\
+	-lboost_unit_test_framework
+
+check_PROGRAMS =						\
+	read1.test write1.test escape.test
+
+AM_DEFAULT_SOURCE_EXT = .cc
+
+TESTS = $(check_PROGRAMS)
+
diff --git a/stomp/testsuite/escape.cc b/stomp/testsuite/escape.cc
new file mode 100644
index 00000000..cad9aa22
--- /dev/null
+++ b/stomp/testsuite/escape.cc
@@ -0,0 +1,29 @@
+
+#define BOOST_TEST_DYN_LINK
+#define BOOST_TEST_MODULE snapper
+
+#include <boost/test/unit_test.hpp>
+
+#include "../Stomp.h"
+
+
+using namespace std;
+using namespace Stomp;
+
+
+BOOST_AUTO_TEST_CASE(escape)
+{
+    BOOST_CHECK_EQUAL(Stomp::escape_header("hello"), "hello");
+
+    BOOST_CHECK_EQUAL(Stomp::escape_header("hello\nworld"), "hello\\nworld");
+    BOOST_CHECK_EQUAL(Stomp::escape_header("hello:world"), "hello\\cworld");
+}
+
+
+BOOST_AUTO_TEST_CASE(unescape)
+{
+    BOOST_CHECK_EQUAL(Stomp::unescape_header("hello"), "hello");
+
+    BOOST_CHECK_EQUAL(Stomp::unescape_header("hello\\nworld"), "hello\nworld");
+    BOOST_CHECK_EQUAL(Stomp::unescape_header("hello\\cworld"), "hello:world");
+}
diff --git a/stomp/testsuite/read1.cc b/stomp/testsuite/read1.cc
new file mode 100644
index 00000000..2d89dd70
--- /dev/null
+++ b/stomp/testsuite/read1.cc
@@ -0,0 +1,73 @@
+
+#define BOOST_TEST_DYN_LINK
+#define BOOST_TEST_MODULE snapper
+
+#include <boost/test/unit_test.hpp>
+
+#include "../Stomp.h"
+
+
+using namespace std;
+using namespace Stomp;
+
+
+const string null("\0", 1);
+
+
+BOOST_AUTO_TEST_CASE(test1)
+{
+    // no optional content-lenght
+
+    istringstream s1("HELLO\nkey:value\n\nWORLD" + null);
+    istream s2(s1.rdbuf());
+
+    Message msg = read_message(s2);
+
+    BOOST_CHECK_EQUAL(s2.peek(), -1);
+
+    BOOST_CHECK_EQUAL(msg.command, "HELLO");
+
+    BOOST_CHECK_EQUAL(msg.headers.size(), 1);
+    BOOST_CHECK_EQUAL(msg.headers["key"], "value");
+
+    BOOST_CHECK_EQUAL(msg.body, "WORLD");
+}
+
+
+BOOST_AUTO_TEST_CASE(test2)
+{
+    // optional content-lenght
+
+    istringstream s1("HELLO\nkey:value\ncontent-length:5\n\nWORLD" + null);
+    istream s2(s1.rdbuf());
+
+    Message msg = read_message(s2);
+
+    BOOST_CHECK_EQUAL(s2.peek(), -1);
+
+    BOOST_CHECK_EQUAL(msg.command, "HELLO");
+
+    BOOST_CHECK_EQUAL(msg.headers.size(), 2);
+    BOOST_CHECK_EQUAL(msg.headers["key"], "value");
+    BOOST_CHECK_EQUAL(msg.headers["content-length"], "5");
+
+    BOOST_CHECK_EQUAL(msg.body, "WORLD");
+}
+
+
+BOOST_AUTO_TEST_CASE(escape1)
+{
+    // special characters in header
+
+    istringstream s1("HELLO\nGermany\\cSpain:2\\c1\n\nWORLD" + null);
+    istream s2(s1.rdbuf());
+
+    Message msg = read_message(s2);
+
+    BOOST_CHECK_EQUAL(msg.command, "HELLO");
+
+    BOOST_CHECK_EQUAL(msg.headers.size(), 1);
+    BOOST_CHECK_EQUAL(msg.headers["Germany:Spain"], "2:1");
+
+    BOOST_CHECK_EQUAL(msg.body, "WORLD");
+}
diff --git a/stomp/testsuite/write1.cc b/stomp/testsuite/write1.cc
new file mode 100644
index 00000000..a231605d
--- /dev/null
+++ b/stomp/testsuite/write1.cc
@@ -0,0 +1,46 @@
+
+#define BOOST_TEST_DYN_LINK
+#define BOOST_TEST_MODULE snapper
+
+#include <boost/test/unit_test.hpp>
+
+#include "../Stomp.h"
+
+
+using namespace std;
+using namespace Stomp;
+
+
+const string null("\0", 1);
+
+
+BOOST_AUTO_TEST_CASE(test1)
+{
+    Message msg;
+    msg.command = "HELLO";
+    msg.headers["key"] = "value";
+    msg.body = "WORLD";
+
+    ostringstream s1;
+    write_message(s1, msg);
+    string s2 = s1.str();
+
+    BOOST_CHECK_EQUAL(s2, "HELLO\nkey:value\n\nWORLD" + null);
+}
+
+
+BOOST_AUTO_TEST_CASE(escape1)
+{
+    // special characters in header
+
+    Message msg;
+    msg.command = "HELLO";
+    msg.headers["Germany:Spain"] = "2:1";
+    msg.body = "WORLD";
+
+    ostringstream s1;
+    write_message(s1, msg);
+    string s2 = s1.str();
+
+    BOOST_CHECK_EQUAL(s2, "HELLO\nGermany\\cSpain:2\\c1\n\nWORLD" + null);
+}
diff --git a/zypp-plugin/Makefile.am b/zypp-plugin/Makefile.am
index 3e4ddf6c..2fd99b8c 100644
--- a/zypp-plugin/Makefile.am
+++ b/zypp-plugin/Makefile.am
@@ -6,15 +6,18 @@
 plugin_PROGRAMS = snapper-zypp-plugin
 
 AM_CPPFLAGS = $(DBUS_CFLAGS) $(XML2_CFLAGS) $(JSONC_CFLAGS)
+
 snapper_zypp_plugin_SOURCES = \
     snapper_zypp_plugin.cc \
     solvable_matcher.cc solvable_matcher.h \
     zypp_commit_plugin.cc zypp_commit_plugin.h \
     zypp_plugin.cc zypp_plugin.h
+
 snapper_zypp_plugin_LDADD = \
     ../client/libclient.la \
     ../snapper/libsnapper.la \
     ../dbus/libdbus.la \
+    ../stomp/libstomp.la \
     $(JSONC_LIBS)
 
 check_PROGRAMS = regex.test forwarding-zypp-plugin
@@ -22,16 +25,21 @@
 forwarding_zypp_plugin_SOURCES = \
     forwarding_zypp_plugin.cc \
     zypp_plugin.cc zypp_plugin.h
+
 forwarding_zypp_plugin_LDADD = \
     ../snapper/libsnapper.la \
+    ../stomp/libstomp.la \
     -lboost_system \
     -lpthread
 
 TESTS = regex.test
+
 regex_test_SOURCES = regex_test.cc \
     solvable_matcher.cc solvable_matcher.h
+
 regex_test_LDADD = \
     ../snapper/libsnapper.la \
+    ../stomp/libstomp.la \
     $(XML2_LIBS) \
     -lboost_unit_test_framework
 
diff --git a/zypp-plugin/zypp_plugin.cc b/zypp-plugin/zypp_plugin.cc
index 26403376..943963f4 100644
--- a/zypp-plugin/zypp_plugin.cc
+++ b/zypp-plugin/zypp_plugin.cc
@@ -38,72 +38,6 @@
     return 0;
 }
 
-void ZyppPlugin::write_message(ostream& os, const Message& msg) {
-    os << msg.command << endl;
-    for(auto it: msg.headers) {
-	os << it.first << ':' << it.second << endl;
-    }
-    os << endl;
-    os << msg.body << '\0';
-    os.flush();
-}
-
-ZyppPlugin::Message ZyppPlugin::read_message(istream& is) {
-    enum class State {
-		      Start,
-		      Headers,
-		      Body
-    } state = State::Start;
-
-    Message msg;
-
-    while(!is.eof()) {
-	string line;
-
-	getline(is, line);
-	boost::trim_right(line);
-
-	if (state == State::Start) {
-	    if (is.eof())
-		return msg; //empty
-
-	    if (line.empty())
-		continue;
-
-	    static const snapper::Regex rx_word("^[A-Za-z0-9_]+$");
-	    if (rx_word.match(line)) {
-		msg = Message();
-		msg.command = line;
-		state = State::Headers;
-	    }
-	    else {
-		throw runtime_error("Plugin protocol error: expected a command. Got '" + line + "'");
-	    }
-	}
-	else if (state == State::Headers) {
-	    if (line.empty()) {
-		state = State::Body;
-		getline(is, msg.body, '\0');
-
-		return msg;
-	    }
-	    else {
-		static const snapper::Regex rx_header("^([A-Za-z0-9_]+):[ \t]*(.+)$");
-		if (rx_header.match(line)) {
-		    string key = rx_header.cap(1);
-		    string value = rx_header.cap(2);
-		    msg.headers[key] = value;
-		}
-		else {
-		    throw runtime_error("Plugin protocol error: expected a header or new line. Got '" + line + "'");
-		}
-	    }
-	}
-    }
-
-    throw runtime_error("Plugin protocol error: expected a message, got a part of it");
-}
-
 ZyppPlugin::Message ZyppPlugin::dispatch(const Message& msg) {
     if (msg.command == "_DISCONNECT") {
 	return ack();
diff --git a/zypp-plugin/zypp_plugin.h b/zypp-plugin/zypp_plugin.h
index cf51d05a..ed356178 100644
--- a/zypp-plugin/zypp_plugin.h
+++ b/zypp-plugin/zypp_plugin.h
@@ -23,18 +23,15 @@
 #define ZYPP_PLUGIN_H
 
 #include <iostream>
-#include <map>
-#include <string>
 
-class ZyppPlugin {
+#include "../stomp/Stomp.h"
+
+class ZyppPlugin
+{
 public:
     // Plugin message aka frame
     // https://doc.opensuse.org/projects/libzypp/SLE12SP2/zypp-plugins.html
-    struct Message {
-	std::string command;
-	std::map<std::string, std::string> headers;
-	std::string body;
-    };
+    using Message = Stomp::Message;
 
     /// Where the protocol reads from
     std::istream& pin;
@@ -49,24 +46,22 @@
 	, pout(out)
 	, plog(log)
     {}
-    virtual ~ZyppPlugin() {}
+    virtual ~ZyppPlugin() = default;
 
     virtual int main();
 
 protected:
+
+    Message read_message(std::istream& is) const { return Stomp::read_message(is); }
+    void write_message(std::ostream& os, const Message& msg) const { Stomp::write_message(os, msg); }
+
     /// Handle a message and return a reply.
     // Derived classes should override it.
     // The base acks a _DISCONNECT and replies _ENOMETHOD to everything else.
     virtual Message dispatch(const Message&);
 
-    Message read_message(std::istream& is);
-    void write_message(std::ostream& os, const Message& msg);
+    Message ack() const { return Stomp::ack(); }
 
-    Message ack() {
-	Message a;
-	a.command = "ACK";
-	return a;
-    }
 };
 
-#endif //ZYPP_PLUGIN_H
+#endif
