From 88fff8264bdfff7e8e7a422e531d9762d029ffb7 Mon Sep 17 00:00:00 2001 From: Hunter Kehoe Date: Sat, 6 Jun 2026 10:37:46 -0600 Subject: [PATCH 1/4] support delete via GET --- docs/publish.md | 27 ++++++++++++++--- server/server.go | 3 ++ server/server_test.go | 69 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 4 deletions(-) diff --git a/docs/publish.md b/docs/publish.md index 9ac600e9..5149a232 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -2777,16 +2777,20 @@ Here's an example of a dead man's switch that sends an alert if the script stops ### Canceling scheduled notifications You can cancel a scheduled message before it is delivered by sending a DELETE request to the -`//` endpoint, just like [deleting notifications](#deleting-notifications). This will remove the -scheduled message from the server so it will never be delivered, and emit a `message_delete` event to any subscribers. +`//` endpoint, just like [deleting notifications](#deleting-notifications). Alternatively, you can send a `GET` +request to `///delete`. This will remove the scheduled message from the server so it will never be delivered, +and emit a `message_delete` event to any subscribers. === "Command line (curl)" ```bash # Schedule a reminder for 2 hours from now curl -H "In: 2h" -d "Take a break!" ntfy.sh/mytopic/break-reminder - # Changed your mind? Cancel the scheduled message + # Changed your mind? Cancel the scheduled message via DELETE curl -X DELETE ntfy.sh/mytopic/break-reminder + + # Or cancel it via GET + curl ntfy.sh/mytopic/break-reminder/delete ``` === "ntfy CLI" @@ -4154,25 +4158,40 @@ An example response from the server with the `message_clear` event may look like ### Deleting notifications Deleting a notification means **removing it from the notification drawer and from the client's database**. -To do this, send a DELETE request to the `//` endpoint. This will emit a `message_delete` event +To do this, send a `DELETE` request to the `//` endpoint. This will emit a `message_delete` event that is used by the clients (web app and Android app) to remove the notification entirely. +Alternatively, if your client has limited HTTP support (e.g. webhooks or IoT devices), you can also delete a message by sending +a `GET` request to `///delete`. + === "Command line (curl)" ```bash + # Via DELETE method curl -X DELETE ntfy.sh/mytopic/my-download-123 + + # Via GET method + curl ntfy.sh/mytopic/my-download-123/delete ``` === "HTTP" ``` http DELETE /mytopic/my-download-123 HTTP/1.1 Host: ntfy.sh + + # Or using GET + GET /mytopic/my-download-123/delete HTTP/1.1 + Host: ntfy.sh ``` === "JavaScript" ``` javascript + // Via DELETE method await fetch('https://ntfy.sh/mytopic/my-download-123', { method: 'DELETE' }); + + // Via GET method + await fetch('https://ntfy.sh/mytopic/my-download-123/delete'); ``` === "Go" diff --git a/server/server.go b/server/server.go index c380bd26..d4586728 100644 --- a/server/server.go +++ b/server/server.go @@ -91,6 +91,7 @@ var ( publishPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/(publish|send|trigger)$`) updatePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[-_A-Za-z0-9]{1,64}$`) clearPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[-_A-Za-z0-9]{1,64}/(read|clear)$`) + deletePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[-_A-Za-z0-9]{1,64}/delete$`) sequenceIDRegex = topicRegex webConfigPath = "/config.js" @@ -645,6 +646,8 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublish))(w, r, v) } else if r.Method == http.MethodDelete && updatePathRegex.MatchString(r.URL.Path) { return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handleDelete))(w, r, v) + } else if r.Method == http.MethodGet && deletePathRegex.MatchString(r.URL.Path) { + return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handleDelete))(w, r, v) } else if r.Method == http.MethodPut && clearPathRegex.MatchString(r.URL.Path) { return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handleClear))(w, r, v) } else if r.Method == http.MethodGet && publishPathRegex.MatchString(r.URL.Path) { diff --git a/server/server_test.go b/server/server_test.go index bb4ddfba..e1d4afe8 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -4026,6 +4026,40 @@ func TestServer_DeleteMessage(t *testing.T) { }) } +func TestServer_DeleteMessage_GET(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + t.Parallel() + s := newTestServer(t, newTestConfig(t, databaseURL)) + + // Publish a message with a sequence ID + response := request(t, s, "PUT", "/mytopic/seq123", "original message", nil) + require.Equal(t, 200, response.Code) + msg := toMessage(t, response.Body.String()) + require.Equal(t, "seq123", msg.SequenceID) + require.Equal(t, "message", msg.Event) + + // Delete the message using GET method (/topic/seq/delete) + response = request(t, s, "GET", "/mytopic/seq123/delete", "", nil) + require.Equal(t, 200, response.Code) + deleteMsg := toMessage(t, response.Body.String()) + require.Equal(t, "seq123", deleteMsg.SequenceID) + require.Equal(t, "message_delete", deleteMsg.Event) + + // Poll and verify both messages are returned + response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil) + require.Equal(t, 200, response.Code) + lines := strings.Split(strings.TrimSpace(response.Body.String()), "\n") + require.Equal(t, 2, len(lines)) + + msg1 := toMessage(t, lines[0]) + msg2 := toMessage(t, lines[1]) + require.Equal(t, "message", msg1.Event) + require.Equal(t, "message_delete", msg2.Event) + require.Equal(t, "seq123", msg1.SequenceID) + require.Equal(t, "seq123", msg2.SequenceID) + }) +} + func TestServer_ClearMessage(t *testing.T) { forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() @@ -4286,6 +4320,41 @@ func TestServer_DeleteScheduledMessage(t *testing.T) { }) } +func TestServer_DeleteScheduledMessage_GET(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + t.Parallel() + s := newTestServer(t, newTestConfig(t, databaseURL)) + + // Publish a scheduled message (future delivery) + response := request(t, s, "PUT", "/mytopic/delete-sched-seq?delay=1h", "scheduled message to delete", nil) + require.Equal(t, 200, response.Code) + msg := toMessage(t, response.Body.String()) + require.Equal(t, "delete-sched-seq", msg.SequenceID) + + // Verify scheduled message exists + response = request(t, s, "GET", "/mytopic/json?poll=1&scheduled=1", "", nil) + require.Equal(t, 200, response.Code) + messages := toMessages(t, response.Body.String()) + require.Equal(t, 1, len(messages)) + require.Equal(t, "scheduled message to delete", messages[0].Message) + + // Delete the scheduled message using GET method (/topic/seq/delete) + response = request(t, s, "GET", "/mytopic/delete-sched-seq/delete", "", nil) + require.Equal(t, 200, response.Code) + deleteMsg := toMessage(t, response.Body.String()) + require.Equal(t, "delete-sched-seq", deleteMsg.SequenceID) + require.Equal(t, "message_delete", deleteMsg.Event) + + // Verify scheduled message was deleted, only delete event remains + response = request(t, s, "GET", "/mytopic/json?poll=1&scheduled=1", "", nil) + require.Equal(t, 200, response.Code) + messages = toMessages(t, response.Body.String()) + require.Equal(t, 1, len(messages)) + require.Equal(t, "message_delete", messages[0].Event) + require.Equal(t, "delete-sched-seq", messages[0].SequenceID) + }) +} + func TestServer_UpdateScheduledMessage_TopicScoped(t *testing.T) { forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() From 26bc28ae2471b5d36bd58b48d2faaffed41e0461 Mon Sep 17 00:00:00 2001 From: Hunter Kehoe Date: Sat, 6 Jun 2026 10:38:09 -0600 Subject: [PATCH 2/4] support clear|read via GET --- docs/publish.md | 17 ++++++++++++++++- server/server.go | 2 +- server/server_test.go | 27 +++++++++++++++++++++++++++ 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/docs/publish.md b/docs/publish.md index 5149a232..2150fed7 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -4104,26 +4104,41 @@ field the response. A sequence of updates may look like this (first example from ### Clearing notifications Clearing a notification means **marking it as read and dismissing it from the notification drawer**. -To do this, send a PUT request to the `///clear` endpoint (or `///read` as an alias). +To do this, send a `PUT` request to the `///clear` endpoint (or `///read` as an alias). This will then emit a `message_clear` event that is used by the clients (web app and Android app) to update the read status and dismiss the notification. +Alternatively, if your client has limited HTTP support, you can send a `GET` request to the same endpoints: +`GET ///clear` or `GET ///read`. + === "Command line (curl)" ```bash + # Via PUT method curl -X PUT ntfy.sh/mytopic/my-download-123/clear + + # Via GET method + curl ntfy.sh/mytopic/my-download-123/clear ``` === "HTTP" ``` http PUT /mytopic/my-download-123/clear HTTP/1.1 Host: ntfy.sh + + # Or using GET + GET /mytopic/my-download-123/clear HTTP/1.1 + Host: ntfy.sh ``` === "JavaScript" ``` javascript + // Via PUT method await fetch('https://ntfy.sh/mytopic/my-download-123/clear', { method: 'PUT' }); + + // Via GET method + await fetch('https://ntfy.sh/mytopic/my-download-123/clear'); ``` === "Go" diff --git a/server/server.go b/server/server.go index d4586728..31977dff 100644 --- a/server/server.go +++ b/server/server.go @@ -648,7 +648,7 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handleDelete))(w, r, v) } else if r.Method == http.MethodGet && deletePathRegex.MatchString(r.URL.Path) { return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handleDelete))(w, r, v) - } else if r.Method == http.MethodPut && clearPathRegex.MatchString(r.URL.Path) { + } else if (r.Method == http.MethodGet || r.Method == http.MethodPut) && clearPathRegex.MatchString(r.URL.Path) { return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handleClear))(w, r, v) } else if r.Method == http.MethodGet && publishPathRegex.MatchString(r.URL.Path) { return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublish))(w, r, v) diff --git a/server/server_test.go b/server/server_test.go index e1d4afe8..d768203f 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -4113,6 +4113,33 @@ func TestServer_ClearMessage_ReadEndpoint(t *testing.T) { }) } +func TestServer_ClearMessage_GET(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + t.Parallel() + s := newTestServer(t, newTestConfig(t, databaseURL)) + + // 1. Test GET /topic/seq-id/clear + response := request(t, s, "PUT", "/mytopic/seq456", "original message 1", nil) + require.Equal(t, 200, response.Code) + + response = request(t, s, "GET", "/mytopic/seq456/clear", "", nil) + require.Equal(t, 200, response.Code) + clearMsg1 := toMessage(t, response.Body.String()) + require.Equal(t, "seq456", clearMsg1.SequenceID) + require.Equal(t, "message_clear", clearMsg1.Event) + + // 2. Test GET /topic/seq-id/read + response = request(t, s, "PUT", "/mytopic/seq789", "original message 2", nil) + require.Equal(t, 200, response.Code) + + response = request(t, s, "GET", "/mytopic/seq789/read", "", nil) + require.Equal(t, 200, response.Code) + clearMsg2 := toMessage(t, response.Body.String()) + require.Equal(t, "seq789", clearMsg2.SequenceID) + require.Equal(t, "message_clear", clearMsg2.Event) + }) +} + func TestServer_UpdateMessage(t *testing.T) { forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() From 737163ba59880d066d06806cfac735c1180247c6 Mon Sep 17 00:00:00 2001 From: Hunter Kehoe Date: Sat, 6 Jun 2026 10:42:25 -0600 Subject: [PATCH 3/4] update release notes --- docs/releases.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/releases.md b/docs/releases.md index 5af903d7..2a311766 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1948,6 +1948,12 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release ## Not released yet +### ntfy server v2.25.0 (UNRELEASED) + +**Features:** + +* You can how clear/read messages and delete messages with a GET request ([#1771](https://github.com/binwiederhier/ntfy/issues/1771), thanks to [@lemmi](https://github.com/lemmi) for reporting and to [@wunter8](https://github.com/wunter8) for implementing) + ### ntfy Android v1.25.x (UNRELEASED) This release makes the "connection lost" alert configurable and turns it off by default. Folks did not like it and many reached out From c44575d7a19b2a9b62f21d8543552c0d0cb1c627 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Tue, 23 Jun 2026 11:52:18 -0400 Subject: [PATCH 4/4] Typo --- docs/releases.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/releases.md b/docs/releases.md index 7a797213..a52e3218 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1962,7 +1962,7 @@ since I do have to reset emails on a regular basis. * Add password reset via emailed magic link, with a "Forgot password" link on the login page and a `ntfy user reset-pass` CLI command for admins * Rework email verification to use durable, single-use, expiring magic links instead of in-memory 6-digit codes, and add a "primary" email (used for account recovery and as the `X-Email: yes` target) with verified/unverified state in the account UI -* You can how clear/read messages and delete messages with a GET request ([#1771](https://github.com/binwiederhier/ntfy/issues/1771), thanks to [@lemmi](https://github.com/lemmi) for reporting and to [@wunter8](https://github.com/wunter8) for implementing) +* You can now clear/read messages and delete messages with a GET request ([#1771](https://github.com/binwiederhier/ntfy/issues/1771), thanks to [@lemmi](https://github.com/lemmi) for reporting and to [@wunter8](https://github.com/wunter8) for implementing) **Bug fixes + maintenance:**