diff --git a/.gitignore b/.gitignore
index 93a1dee20c754d17b05a55348c5f840fe2ab403a..6dffcf55c38a36f694f75306ab70e5c5c17460ec 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,4 +2,5 @@ dist/
 build/
 .idea/
 server/docs/
+tools/fbsend/fbsend
 *.iml
diff --git a/config/config.go b/config/config.go
index 9e1640a8e0265027fe7d9cbe90f25aa5882ceece..90dbe7cba530990e7c77908118b42cefa437fc1c 100644
--- a/config/config.go
+++ b/config/config.go
@@ -7,14 +7,15 @@ import (
 
 // Defines default config settings
 const (
-	DefaultListenHTTP        = ":80"
-	DefaultCacheDuration     = 12 * time.Hour
-	DefaultKeepaliveInterval = 30 * time.Second
-	DefaultManagerInterval   = time.Minute
-	DefaultAtSenderInterval  = 10 * time.Second
-	DefaultMinDelay          = 10 * time.Second
-	DefaultMaxDelay          = 3 * 24 * time.Hour
-	DefaultMessageLimit      = 512
+	DefaultListenHTTP                = ":80"
+	DefaultCacheDuration             = 12 * time.Hour
+	DefaultKeepaliveInterval         = 30 * time.Second
+	DefaultManagerInterval           = time.Minute
+	DefaultAtSenderInterval          = 10 * time.Second
+	DefaultMinDelay                  = 10 * time.Second
+	DefaultMaxDelay                  = 3 * 24 * time.Hour
+	DefaultMessageLimit              = 512
+	DefaultFirebaseKeepaliveInterval = time.Hour
 )
 
 // Defines all the limits
@@ -40,6 +41,7 @@ type Config struct {
 	KeepaliveInterval            time.Duration
 	ManagerInterval              time.Duration
 	AtSenderInterval             time.Duration
+	FirebaseKeepaliveInterval    time.Duration
 	MessageLimit                 int
 	MinDelay                     time.Duration
 	MaxDelay                     time.Duration
@@ -66,6 +68,7 @@ func New(listenHTTP string) *Config {
 		MinDelay:                     DefaultMinDelay,
 		MaxDelay:                     DefaultMaxDelay,
 		AtSenderInterval:             DefaultAtSenderInterval,
+		FirebaseKeepaliveInterval:    DefaultFirebaseKeepaliveInterval,
 		GlobalTopicLimit:             DefaultGlobalTopicLimit,
 		VisitorRequestLimitBurst:     DefaultVisitorRequestLimitBurst,
 		VisitorRequestLimitReplenish: DefaultVisitorRequestLimitReplenish,
diff --git a/server/server.go b/server/server.go
index 6fadd9d9cd3c58a1dbc07761bc0a24fe6ebbd2c2..0382b51d5a60cac1d0323e21a8db32a8e229dbd8 100644
--- a/server/server.go
+++ b/server/server.go
@@ -105,6 +105,10 @@ var (
 	errHTTPTooManyRequests = &errHTTP{http.StatusTooManyRequests, http.StatusText(http.StatusTooManyRequests)}
 )
 
+const (
+	firebaseControlTopic = "~control" // See Android if changed
+)
+
 // New instantiates a new Server. It creates the cache and adds a Firebase
 // subscriber (if configured).
 func New(conf *config.Config) (*Server, error) {
@@ -152,9 +156,17 @@ func createFirebaseSubscriber(conf *config.Config) (subscriber, error) {
 		return nil, err
 	}
 	return func(m *message) error {
-		_, err := msg.Send(context.Background(), &messaging.Message{
-			Topic: m.Topic,
-			Data: map[string]string{
+		var data map[string]string // Matches https://ntfy.sh/docs/subscribe/api/#json-message-format
+		switch m.Event {
+		case keepaliveEvent, openEvent:
+			data = map[string]string{
+				"id":    m.ID,
+				"time":  fmt.Sprintf("%d", m.Time),
+				"event": m.Event,
+				"topic": m.Topic,
+			}
+		case messageEvent:
+			data = map[string]string{
 				"id":       m.ID,
 				"time":     fmt.Sprintf("%d", m.Time),
 				"event":    m.Event,
@@ -163,7 +175,11 @@ func createFirebaseSubscriber(conf *config.Config) (subscriber, error) {
 				"tags":     strings.Join(m.Tags, ","),
 				"title":    m.Title,
 				"message":  m.Message,
-			},
+			}
+		}
+		_, err := msg.Send(context.Background(), &messaging.Message{
+			Topic: m.Topic,
+			Data:  data,
 		})
 		return err
 	}, nil
@@ -188,6 +204,17 @@ func (s *Server) Run() error {
 			}
 		}
 	}()
+	if s.firebase != nil {
+		go func() {
+			ticker := time.NewTicker(s.config.FirebaseKeepaliveInterval)
+			for {
+				<-ticker.C
+				if err := s.firebase(newKeepaliveMessage(firebaseControlTopic)); err != nil {
+					log.Printf("error sending Firebase keepalive message: %s", err.Error())
+				}
+			}
+		}()
+	}
 	listenStr := fmt.Sprintf("%s/http", s.config.ListenHTTP)
 	if s.config.ListenHTTPS != "" {
 		listenStr += fmt.Sprintf(" %s/https", s.config.ListenHTTPS)
diff --git a/tools/fbsend/main.go b/tools/fbsend/main.go
new file mode 100644
index 0000000000000000000000000000000000000000..cd3a06d168e0f35de19d54b03dd35bbbcd2c2207
--- /dev/null
+++ b/tools/fbsend/main.go
@@ -0,0 +1,50 @@
+package main
+
+import (
+	"context"
+	firebase "firebase.google.com/go"
+	"firebase.google.com/go/messaging"
+	"flag"
+	"fmt"
+	"google.golang.org/api/option"
+	"os"
+	"strings"
+)
+
+func main() {
+	conffile := flag.String("config", "/etc/fbsend/fbsend.json", "config file")
+	flag.Parse()
+	if flag.NArg() < 2 {
+		fail("Syntax: fbsend [-config FILE] topic key=value ...")
+	}
+	topic := flag.Arg(0)
+	data := make(map[string]string)
+	for i := 1; i < flag.NArg(); i++ {
+		kv := strings.SplitN(flag.Arg(i), "=", 2)
+		if len(kv) != 2 {
+			fail(fmt.Sprintf("Invalid argument: %s (%v)", flag.Arg(i), kv))
+		}
+		data[kv[0]] = kv[1]
+	}
+	fb, err := firebase.NewApp(context.Background(), nil, option.WithCredentialsFile(*conffile))
+	if err != nil {
+		fail(err.Error())
+	}
+	msg, err := fb.Messaging(context.Background())
+	if err != nil {
+		fail(err.Error())
+	}
+	_, err = msg.Send(context.Background(), &messaging.Message{
+		Topic: topic,
+		Data:  data,
+	})
+	if err != nil {
+		fail(err.Error())
+	}
+	fmt.Println("Sent successfully")
+}
+
+func fail(s string) {
+	fmt.Println(s)
+	os.Exit(1)
+}