diff --git a/docs/publish.md b/docs/publish.md
index 5229a537..48d8ca56 100644
--- a/docs/publish.md
+++ b/docs/publish.md
@@ -850,27 +850,32 @@ To define actions using the `X-Actions` header (or any of its aliases: `Actions`
     <action1>, <label1>, paramN=... [; <action2>, <label2>, ...]
     ```
 
-The `action=` and `label=` prefix are optional in all actions, and the `url=` prefix is optional in the `view` and `http` action.
-The format has **some limitations**: You cannot use `,` or `;` in any of the values, and depending on your language/library, UTF-8
-characters may not work. Use the [JSON array format](#using-a-json-array) instead to overcome these limitations.
+Multiple actions are separated by a semicolon (`;`), and key/value pairs are separated by commas (`,`). Values may be 
+quoted with double quotes (`"`) or single quotes (`'`) if the value itself contains commas or semicolons. 
+
+The `action=` and `label=` prefix are optional in all actions, and the `url=` prefix is optional in the `view` and 
+`http` action. The only limitation of this format is that depending on your language/library, UTF-8 characters may not 
+work. If they don't, use the [JSON array format](#using-a-json-array) instead.
 
 As an example, here's how you can create the above notification using this format. Refer to the [`view` action](#open-websiteapp) and 
 [`http` action](#send-http-request) section for details on the specific actions:
 
 === "Command line (curl)"
     ```
+    body='{"temperature": 65}'
     curl \
         -d "You left the house. Turn down the A/C?" \
         -H "Actions: view, Open portal, https://home.nest.com/, clear=true; \
-                     http, Turn down, https://api.nest.com/device/XZ1D2, body=target_temp_f=65" \
-    ntfy.sh/myhome
+                     http, Turn down, https://api.nest.com/, body='$body'" \
+        ntfy.sh/myhome
     ```
 
 === "ntfy CLI"
     ```
+    body='{"temperature": 65}'
     ntfy publish \
         --actions="view, Open portal, https://home.nest.com/, clear=true; \
-                   http, Turn down, https://api.nest.com/device/XZ1D2, body=target_temp_f=65" \
+                   http, Turn down, https://api.nest.com/, body='$body'" \
         myhome \
         "You left the house. Turn down the A/C?"
     ```
@@ -879,7 +884,7 @@ As an example, here's how you can create the above notification using this forma
     ``` http
     POST /myhome HTTP/1.1
     Host: ntfy.sh
-    Actions: view, Open portal, https://home.nest.com/, clear=true; http, Turn down, https://api.nest.com/device/XZ1D2, body=target_temp_f=65
+    Actions: view, Open portal, https://home.nest.com/, clear=true; http, Turn down, https://api.nest.com/, body='{"temperature": 65}'
 
     You left the house. Turn down the A/C?
     ```
@@ -890,7 +895,7 @@ As an example, here's how you can create the above notification using this forma
         method: 'POST',
         body: 'You left the house. Turn down the A/C?',
         headers: { 
-            'Actions': 'view, Open portal, https://home.nest.com/, clear=true; http, Turn down, https://api.nest.com/device/XZ1D2, body=target_temp_f=65' 
+            'Actions': 'view, Open portal, https://home.nest.com/, clear=true; http, Turn down, https://api.nest.com/, body=\'{"temperature": 65}\'' 
         }
     })
     ```
@@ -898,14 +903,14 @@ As an example, here's how you can create the above notification using this forma
 === "Go"
     ``` go
     req, _ := http.NewRequest("POST", "https://ntfy.sh/myhome", strings.NewReader("You left the house. Turn down the A/C?"))
-    req.Header.Set("Actions", "view, Open portal, https://home.nest.com/, clear=true; http, Turn down, https://api.nest.com/device/XZ1D2, body=target_temp_f=65")
+    req.Header.Set("Actions", "view, Open portal, https://home.nest.com/, clear=true; http, Turn down, https://api.nest.com/, body='{\"temperature\": 65}'")
     http.DefaultClient.Do(req)
     ```
 
 === "PowerShell"
     ``` powershell
     $uri = "https://ntfy.sh/myhome"
-    $headers = @{ Actions="view, Open portal, https://home.nest.com/, clear=true; http, Turn down, https://api.nest.com/device/XZ1D2, body=target_temp_f=65" }
+    $headers = @{ Actions="view, Open portal, https://home.nest.com/, clear=true; http, Turn down, https://api.nest.com/, body='{\"temperature\": 65}'" }
     $body = "You left the house. Turn down the A/C?"
     Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing
     ```
@@ -914,7 +919,7 @@ As an example, here's how you can create the above notification using this forma
     ``` python
     requests.post("https://ntfy.sh/myhome",
         data="You left the house. Turn down the A/C?",
-        headers={ "Actions": "view, Open portal, https://home.nest.com/, clear=true; http, Turn down, https://api.nest.com/device/XZ1D2, body=target_temp_f=65" })
+        headers={ "Actions": "view, Open portal, https://home.nest.com/, clear=true; http, Turn down, https://api.nest.com/, body='{\"temperature\": 65}'" })
     ```
 
 === "PHP"
@@ -924,7 +929,7 @@ As an example, here's how you can create the above notification using this forma
             'method' => 'POST',
             'header' =>
                 "Content-Type: text/plain\r\n" .
-                "Actions: view, Open portal, https://home.nest.com/, clear=true; http, Turn down, https://api.nest.com/device/XZ1D2, body=target_temp_f=65",
+                "Actions: view, Open portal, https://home.nest.com/, clear=true; http, Turn down, https://api.nest.com/, body='{\"temperature\": 65}'",
             'content' => 'You left the house. Turn down the A/C?'
         ]
     ]));
@@ -950,8 +955,8 @@ Alternatively, the same actions can be defined as **JSON array**, if the notific
           {
             "action": "http",
             "label": "Turn down",
-            "url": "https://api.nest.com/device/XZ1D2",
-            "body": "target_temp_f=65"
+            "url": "https://api.nest.com/",
+            "body": "{\"temperature\": 65}"
           }
         ]
       }'
@@ -970,8 +975,8 @@ Alternatively, the same actions can be defined as **JSON array**, if the notific
             {
                 "action": "http",
                 "label": "Turn down",
-                "url": "https://api.nest.com/device/XZ1D2",
-                "body": "target_temp_f=65"
+                "url": "https://api.nest.com/",
+                "body": "{\"temperature\": 65}"
             }
         ]' \
         myhome \
@@ -996,8 +1001,8 @@ Alternatively, the same actions can be defined as **JSON array**, if the notific
           {
             "action": "http",
             "label": "Turn down",
-            "url": "https://api.nest.com/device/XZ1D2",
-            "body": "target_temp_f=65"
+            "url": "https://api.nest.com/",
+            "body": "{\"temperature\": 65}"
           }
         ]
     }
@@ -1020,8 +1025,8 @@ Alternatively, the same actions can be defined as **JSON array**, if the notific
                 {
                     action: "http",
                     label: "Turn down",
-                    url: "https://api.nest.com/device/XZ1D2",
-                    body: "target_temp_f=65"
+                    url: "https://api.nest.com/",
+                    body: "{\"temperature\": 65}"
                 }
             ]
         })
@@ -1046,8 +1051,8 @@ Alternatively, the same actions can be defined as **JSON array**, if the notific
           {
             "action": "http",
             "label": "Turn down",
-            "url": "https://api.nest.com/device/XZ1D2",
-            "body": "target_temp_f=65"
+            "url": "https://api.nest.com/",
+            "body": "{\"temperature\": 65}"
           }
         ]
     }`
@@ -1071,8 +1076,8 @@ Alternatively, the same actions can be defined as **JSON array**, if the notific
             @{
                 "action"="http",
                 "label"="Turn down"
-                "url"="https://api.nest.com/device/XZ1D2"
-                "body"="target_temp_f=65"
+                "url"="https://api.nest.com/"
+                "body"="{\"temperature\": 65}"
             }
         )
     } | ConvertTo-Json
@@ -1095,8 +1100,8 @@ Alternatively, the same actions can be defined as **JSON array**, if the notific
                 {
                     "action": "http",
                     "label": "Turn down",
-                    "url": "https://api.nest.com/device/XZ1D2",
-                    "body": "target_temp_f=65"
+                    "url": "https://api.nest.com/",
+                    "body": "{\"temperature\": 65}"
                 }
             ]
         })
@@ -1122,11 +1127,11 @@ Alternatively, the same actions can be defined as **JSON array**, if the notific
                     [
                         "action": "http",
                         "label": "Turn down",
-                        "url": "https://api.nest.com/device/XZ1D2",
+                        "url": "https://api.nest.com/",
                         "headers": [
                             "Authorization": "Bearer ..."
                         ],
-                        "body": "target_temp_f=65"
+                        "body": "{\"temperature\": 65}"
                     ]
                 ]
             ])
diff --git a/docs/releases.md b/docs/releases.md
index 6888e625..c44577bd 100644
--- a/docs/releases.md
+++ b/docs/releases.md
@@ -2,6 +2,22 @@
 Binaries for all releases can be found on the GitHub releases pages for the [ntfy server](https://github.com/binwiederhier/ntfy/releases)
 and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/releases).
 
+<!--
+
+## ntfy Android app v1.13.0 (UNRELEASED)
+
+Bugs:
+* Accurate naming of "mute notifications" from "pause notifications" ([#224](https://github.com/binwiederhier/ntfy/issues/224), 
+  thanks to [@shadow00](https://github.com/shadow00) for reporting)
+
+## ntfy server v1.22.0 (UNRELEASED)
+
+**Features:**
+
+* Better parsing of the user actions, allowing quotes (no ticket)
+
+-->
+
 ## ntfy Android app v1.12.0
 Released Apr 25, 2022
 
diff --git a/server/actions.go b/server/actions.go
new file mode 100644
index 00000000..320c79cd
--- /dev/null
+++ b/server/actions.go
@@ -0,0 +1,307 @@
+package server
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"heckel.io/ntfy/util"
+	"regexp"
+	"strings"
+	"unicode/utf8"
+)
+
+const (
+	actionIDLength = 10
+	actionEOF      = rune(0)
+	actionsMax     = 3
+)
+
+const (
+	actionView      = "view"
+	actionBroadcast = "broadcast"
+	actionHTTP      = "http"
+)
+
+var (
+	actionsAll      = []string{actionView, actionBroadcast, actionHTTP}
+	actionsWithURL  = []string{actionView, actionHTTP}
+	actionsKeyRegex = regexp.MustCompile(`^([-.\w]+)\s*=\s*`)
+)
+
+type actionParser struct {
+	input string
+	pos   int
+}
+
+// parseActions parses the actions string as described in https://ntfy.sh/docs/publish/#action-buttons.
+// It supports both a JSON representation (if the string begins with "[", see parseActionsFromJSON),
+// and the "simple" format, which is more human-readable, but harder to parse (see parseActionsFromSimple).
+func parseActions(s string) (actions []*action, err error) {
+	// Parse JSON or simple format
+	s = strings.TrimSpace(s)
+	if strings.HasPrefix(s, "[") {
+		actions, err = parseActionsFromJSON(s)
+	} else {
+		actions, err = parseActionsFromSimple(s)
+	}
+	if err != nil {
+		return nil, err
+	}
+
+	// Add ID field, ensure correct uppercase/lowercase
+	for i := range actions {
+		actions[i].ID = util.RandomString(actionIDLength)
+		actions[i].Action = strings.ToLower(actions[i].Action)
+		actions[i].Method = strings.ToUpper(actions[i].Method)
+	}
+
+	// Validate
+	if len(actions) > actionsMax {
+		return nil, fmt.Errorf("only %d actions allowed", actionsMax)
+	}
+	for _, action := range actions {
+		if !util.InStringList(actionsAll, action.Action) {
+			return nil, fmt.Errorf("action '%s' unknown", action.Action)
+		} else if action.Label == "" {
+			return nil, fmt.Errorf("parameter 'label' is required")
+		} else if util.InStringList(actionsWithURL, action.Action) && action.URL == "" {
+			return nil, fmt.Errorf("parameter 'url' is required for action '%s'", action.Action)
+		} else if action.Action == actionHTTP && util.InStringList([]string{"GET", "HEAD"}, action.Method) && action.Body != "" {
+			return nil, fmt.Errorf("parameter 'body' cannot be set if method is %s", action.Method)
+		}
+	}
+
+	return actions, nil
+}
+
+// parseActionsFromJSON converts a JSON array into an array of actions
+func parseActionsFromJSON(s string) ([]*action, error) {
+	actions := make([]*action, 0)
+	if err := json.Unmarshal([]byte(s), &actions); err != nil {
+		return nil, err
+	}
+	return actions, nil
+}
+
+// parseActionsFromSimple parses the "simple" actions string (as described in
+// https://ntfy.sh/docs/publish/#action-buttons), into an array of actions.
+//
+// It can parse an actions string like this:
+//    view, "Look ma, commas and \"quotes\" too", url=https://..; action=broadcast, ...
+//
+// It works by advancing the position ("pos") through the input string ("input").
+//
+// The parser is heavily inspired by https://go.dev/src/text/template/parse/lex.go (which
+// is described by Rob Pike in this video: https://www.youtube.com/watch?v=HxaD_trXwRE),
+// though it does not use state functions at all.
+//
+// Other resources:
+//   https://adampresley.github.io/2015/04/12/writing-a-lexer-and-parser-in-go-part-1.html
+//   https://github.com/adampresley/sample-ini-parser/blob/master/services/lexer/lexer/Lexer.go
+//   https://github.com/benbjohnson/sql-parser/blob/master/scanner.go
+//   https://blog.gopheracademy.com/advent-2014/parsers-lexers/
+func parseActionsFromSimple(s string) ([]*action, error) {
+	if !utf8.ValidString(s) {
+		return nil, errors.New("invalid string")
+	}
+	parser := &actionParser{
+		pos:   0,
+		input: s,
+	}
+	return parser.Parse()
+}
+
+// Parse loops trough parseAction() until the end of the string is reached
+func (p *actionParser) Parse() ([]*action, error) {
+	actions := make([]*action, 0)
+	for !p.eof() {
+		a, err := p.parseAction()
+		if err != nil {
+			return nil, err
+		}
+		actions = append(actions, a)
+	}
+	return actions, nil
+}
+
+// parseAction parses the individual sections of an action using parseSection into key/value pairs,
+// and then uses populateAction to interpret the keys/values. The function terminates
+// when EOF or ";" is reached.
+func (p *actionParser) parseAction() (*action, error) {
+	a := newAction()
+	section := 0
+	for {
+		key, value, last, err := p.parseSection()
+		if err != nil {
+			return nil, err
+		}
+		if err := populateAction(a, section, key, value); err != nil {
+			return nil, err
+		}
+		p.slurpSpaces()
+		if last {
+			return a, nil
+		}
+		section++
+	}
+}
+
+// populateAction is the "business logic" of the parser. It applies the key/value
+// pair to the action instance.
+func populateAction(newAction *action, section int, key, value string) error {
+	// Auto-expand keys based on their index
+	if key == "" && section == 0 {
+		key = "action"
+	} else if key == "" && section == 1 {
+		key = "label"
+	} else if key == "" && section == 2 && util.InStringList(actionsWithURL, newAction.Action) {
+		key = "url"
+	}
+
+	// Validate
+	if key == "" {
+		return fmt.Errorf("term '%s' unknown", value)
+	}
+
+	// Populate
+	if strings.HasPrefix(key, "headers.") {
+		newAction.Headers[strings.TrimPrefix(key, "headers.")] = value
+	} else if strings.HasPrefix(key, "extras.") {
+		newAction.Extras[strings.TrimPrefix(key, "extras.")] = value
+	} else {
+		switch strings.ToLower(key) {
+		case "action":
+			newAction.Action = value
+		case "label":
+			newAction.Label = value
+		case "clear":
+			lvalue := strings.ToLower(value)
+			if !util.InStringList([]string{"true", "yes", "1", "false", "no", "0"}, lvalue) {
+				return fmt.Errorf("'clear=%s' not allowed", value)
+			}
+			newAction.Clear = lvalue == "true" || lvalue == "yes" || lvalue == "1"
+		case "url":
+			newAction.URL = value
+		case "method":
+			newAction.Method = value
+		case "body":
+			newAction.Body = value
+		default:
+			return fmt.Errorf("key '%s' unknown", key)
+		}
+	}
+	return nil
+}
+
+// parseSection parses a section ("key=value") and returns a key/value pair. It terminates
+// when EOF or "," is reached.
+func (p *actionParser) parseSection() (key string, value string, last bool, err error) {
+	p.slurpSpaces()
+	key = p.parseKey()
+	r, w := p.peek()
+	if isSectionEnd(r) {
+		p.pos += w
+		last = isLastSection(r)
+		return
+	} else if r == '"' || r == '\'' {
+		value, last, err = p.parseQuotedValue(r)
+		return
+	}
+	value, last = p.parseValue()
+	return
+}
+
+// parseKey uses a regex to determine whether the current position is a key definition ("key =")
+// and returns the key if it is, or an empty string otherwise.
+func (p *actionParser) parseKey() string {
+	matches := actionsKeyRegex.FindStringSubmatch(p.input[p.pos:])
+	if len(matches) == 2 {
+		p.pos += len(matches[0])
+		return matches[1]
+	}
+	return ""
+}
+
+// parseValue reads the input until EOF, "," or ";" and returns the value string. Unlike parseQuotedValue,
+// this function does not support "," or ";" in the value itself, and spaces in the beginning and end of the
+// string are trimmed.
+func (p *actionParser) parseValue() (value string, last bool) {
+	start := p.pos
+	for {
+		r, w := p.peek()
+		if isSectionEnd(r) {
+			last = isLastSection(r)
+			value = strings.TrimSpace(p.input[start:p.pos])
+			p.pos += w
+			return
+		}
+		p.pos += w
+	}
+}
+
+// parseQuotedValue reads the input until it finds an unescaped end quote character ("), and then
+// advances the position beyond the section end. It supports quoting strings using backslash (\).
+func (p *actionParser) parseQuotedValue(quote rune) (value string, last bool, err error) {
+	p.pos++
+	start := p.pos
+	var prev rune
+	for {
+		r, w := p.peek()
+		if r == actionEOF {
+			err = fmt.Errorf("unexpected end of input, quote started at position %d", start)
+			return
+		} else if r == quote && prev != '\\' {
+			value = p.input[start:p.pos]
+			p.pos += w
+
+			// Advance until section end (after "," or ";")
+			p.slurpSpaces()
+			r, w := p.peek()
+			last = isLastSection(r)
+			if !isSectionEnd(r) {
+				err = fmt.Errorf("unexpected character '%c' at position %d", r, p.pos)
+				return
+			}
+			p.pos += w
+			return
+		}
+		prev = r
+		p.pos += w
+	}
+}
+
+// slurpSpaces reads all space characters and advances the position
+func (p *actionParser) slurpSpaces() {
+	for {
+		r, w := p.peek()
+		if r == actionEOF || !isSpace(r) {
+			return
+		}
+		p.pos += w
+	}
+}
+
+// peek returns the next run and its width
+func (p *actionParser) peek() (rune, int) {
+	if p.eof() {
+		return actionEOF, 0
+	}
+	return utf8.DecodeRuneInString(p.input[p.pos:])
+}
+
+// eof returns true if the end of the input has been reached
+func (p *actionParser) eof() bool {
+	return p.pos >= len(p.input)
+}
+
+func isSpace(r rune) bool {
+	return r == ' ' || r == '\t' || r == '\r' || r == '\n'
+}
+
+func isSectionEnd(r rune) bool {
+	return r == actionEOF || r == ';' || r == ','
+}
+
+func isLastSection(r rune) bool {
+	return r == actionEOF || r == ';'
+}
diff --git a/server/actions_test.go b/server/actions_test.go
new file mode 100644
index 00000000..4f16bea0
--- /dev/null
+++ b/server/actions_test.go
@@ -0,0 +1,155 @@
+package server
+
+import (
+	"github.com/stretchr/testify/require"
+	"testing"
+)
+
+func TestParseActions(t *testing.T) {
+	actions, err := parseActions("[]")
+	require.Nil(t, err)
+	require.Empty(t, actions)
+
+	// Basic test
+	actions, err = parseActions("action=http, label=Open door, url=https://door.lan/open; view, Show portal, https://door.lan")
+	require.Nil(t, err)
+	require.Equal(t, 2, len(actions))
+	require.Equal(t, "http", actions[0].Action)
+	require.Equal(t, "Open door", actions[0].Label)
+	require.Equal(t, "https://door.lan/open", actions[0].URL)
+	require.Equal(t, "view", actions[1].Action)
+	require.Equal(t, "Show portal", actions[1].Label)
+	require.Equal(t, "https://door.lan", actions[1].URL)
+
+	// JSON
+	actions, err = parseActions(`[{"action":"http","label":"Open door","url":"https://door.lan/open"}, {"action":"view","label":"Show portal","url":"https://door.lan"}]`)
+	require.Nil(t, err)
+	require.Equal(t, 2, len(actions))
+	require.Equal(t, "http", actions[0].Action)
+	require.Equal(t, "Open door", actions[0].Label)
+	require.Equal(t, "https://door.lan/open", actions[0].URL)
+	require.Equal(t, "view", actions[1].Action)
+	require.Equal(t, "Show portal", actions[1].Label)
+	require.Equal(t, "https://door.lan", actions[1].URL)
+
+	// Other params
+	actions, err = parseActions("action=http, label=Open door, url=https://door.lan/open, body=this is a body, method=PUT")
+	require.Nil(t, err)
+	require.Equal(t, 1, len(actions))
+	require.Equal(t, "http", actions[0].Action)
+	require.Equal(t, "Open door", actions[0].Label)
+	require.Equal(t, "https://door.lan/open", actions[0].URL)
+	require.Equal(t, "PUT", actions[0].Method)
+	require.Equal(t, "this is a body", actions[0].Body)
+
+	// Extras with underscores
+	actions, err = parseActions("action=broadcast, label=Do a thing, extras.command=some command, extras.some_param=a parameter")
+	require.Nil(t, err)
+	require.Equal(t, 1, len(actions))
+	require.Equal(t, "broadcast", actions[0].Action)
+	require.Equal(t, "Do a thing", actions[0].Label)
+	require.Equal(t, 2, len(actions[0].Extras))
+	require.Equal(t, "some command", actions[0].Extras["command"])
+	require.Equal(t, "a parameter", actions[0].Extras["some_param"])
+
+	// Headers with dashes
+	actions, err = parseActions("action=http, label=Send request, url=http://example.com, method=GET, headers.Content-Type=application/json, headers.Authorization=Basic sdasffsf")
+	require.Nil(t, err)
+	require.Equal(t, 1, len(actions))
+	require.Equal(t, "http", actions[0].Action)
+	require.Equal(t, "Send request", actions[0].Label)
+	require.Equal(t, 2, len(actions[0].Headers))
+	require.Equal(t, "application/json", actions[0].Headers["Content-Type"])
+	require.Equal(t, "Basic sdasffsf", actions[0].Headers["Authorization"])
+
+	// Quotes
+	actions, err = parseActions(`action=http, "Look ma, \"quotes\"; and semicolons", url=http://example.com`)
+	require.Nil(t, err)
+	require.Equal(t, 1, len(actions))
+	require.Equal(t, "http", actions[0].Action)
+	require.Equal(t, `Look ma, \"quotes\"; and semicolons`, actions[0].Label)
+	require.Equal(t, `http://example.com`, actions[0].URL)
+
+	// Single quotes
+	actions, err = parseActions(`action=http, '"quotes" and \'single quotes\'', url=http://example.com`)
+	require.Nil(t, err)
+	require.Equal(t, 1, len(actions))
+	require.Equal(t, "http", actions[0].Action)
+	require.Equal(t, `"quotes" and \'single quotes\'`, actions[0].Label)
+	require.Equal(t, `http://example.com`, actions[0].URL)
+
+	// Single quotes (JSON)
+	actions, err = parseActions(`action=http, Post it, url=http://example.com, body='{"temperature": 65}'`)
+	require.Nil(t, err)
+	require.Equal(t, 1, len(actions))
+	require.Equal(t, "http", actions[0].Action)
+	require.Equal(t, "Post it", actions[0].Label)
+	require.Equal(t, `http://example.com`, actions[0].URL)
+	require.Equal(t, `{"temperature": 65}`, actions[0].Body)
+
+	// Out of order
+	actions, err = parseActions(`label="Out of order!" , action="http", url=http://example.com`)
+	require.Nil(t, err)
+	require.Equal(t, 1, len(actions))
+	require.Equal(t, "http", actions[0].Action)
+	require.Equal(t, `Out of order!`, actions[0].Label)
+	require.Equal(t, `http://example.com`, actions[0].URL)
+
+	// Spaces
+	actions, err = parseActions(`action = http, label = 'this is a label', url = "http://google.com"`)
+	require.Nil(t, err)
+	require.Equal(t, 1, len(actions))
+	require.Equal(t, "http", actions[0].Action)
+	require.Equal(t, `this is a label`, actions[0].Label)
+	require.Equal(t, `http://google.com`, actions[0].URL)
+
+	// Non-ASCII
+	actions, err = parseActions(`action = http, 'Кохайтеся а не воюйте, 💙🫤', url = "http://google.com"`)
+	require.Nil(t, err)
+	require.Equal(t, 1, len(actions))
+	require.Equal(t, "http", actions[0].Action)
+	require.Equal(t, `Кохайтеся а не воюйте, 💙🫤`, actions[0].Label)
+	require.Equal(t, `http://google.com`, actions[0].URL)
+
+	// Multiple actions, awkward spacing
+	actions, err = parseActions(`http , 'Make love, not war 💙🫤' , https://ntfy.sh ; view, " yo ", https://x.org`)
+	require.Nil(t, err)
+	require.Equal(t, 2, len(actions))
+	require.Equal(t, "http", actions[0].Action)
+	require.Equal(t, `Make love, not war 💙🫤`, actions[0].Label)
+	require.Equal(t, `https://ntfy.sh`, actions[0].URL)
+	require.Equal(t, "view", actions[1].Action)
+	require.Equal(t, " yo ", actions[1].Label)
+	require.Equal(t, `https://x.org`, actions[1].URL)
+
+	// Invalid syntax
+	_, err = parseActions(`label="Out of order!" x, action="http", url=http://example.com`)
+	require.EqualError(t, err, "unexpected character 'x' at position 22")
+
+	_, err = parseActions(`label="", action="http", url=http://example.com`)
+	require.EqualError(t, err, "parameter 'label' is required")
+
+	_, err = parseActions(`label=, action="http", url=http://example.com`)
+	require.EqualError(t, err, "parameter 'label' is required")
+
+	_, err = parseActions(`label="xx", action="http", url=http://example.com, what is this anyway`)
+	require.EqualError(t, err, "term 'what is this anyway' unknown")
+
+	_, err = parseActions(`fdsfdsf`)
+	require.EqualError(t, err, "action 'fdsfdsf' unknown")
+
+	_, err = parseActions(`aaa=a, "bbb, 'ccc, ddd, eee "`)
+	require.EqualError(t, err, "key 'aaa' unknown")
+
+	_, err = parseActions(`action=http, label="omg the end quote is missing`)
+	require.EqualError(t, err, "unexpected end of input, quote started at position 20")
+
+	_, err = parseActions(`;;;;`)
+	require.EqualError(t, err, "only 3 actions allowed")
+
+	_, err = parseActions(`,,,,,,;;`)
+	require.EqualError(t, err, "term '' unknown")
+
+	_, err = parseActions(`''";,;"`)
+	require.EqualError(t, err, "unexpected character '\"' at position 2")
+}
diff --git a/server/server.go b/server/server.go
index b3336da3..f4369f89 100644
--- a/server/server.go
+++ b/server/server.go
@@ -539,7 +539,7 @@ func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (ca
 	if actionsStr != "" {
 		m.Actions, err = parseActions(actionsStr)
 		if err != nil {
-			return false, false, "", false, err // wrapped errHTTPBadRequestActionsInvalid
+			return false, false, "", false, wrapErrHTTP(errHTTPBadRequestActionsInvalid, err.Error())
 		}
 	}
 	unifiedpush = readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see GET too!
diff --git a/server/types.go b/server/types.go
index 6e40c345..8c4f125b 100644
--- a/server/types.go
+++ b/server/types.go
@@ -56,6 +56,13 @@ type action struct {
 	Extras  map[string]string `json:"extras,omitempty"`  // used in "broadcast" action
 }
 
+func newAction() *action {
+	return &action{
+		Headers: make(map[string]string),
+		Extras:  make(map[string]string),
+	}
+}
+
 // publishMessage is used as input when publishing as JSON
 type publishMessage struct {
 	Topic    string   `json:"topic"`
diff --git a/server/util.go b/server/util.go
index 34d706f8..7c596344 100644
--- a/server/util.go
+++ b/server/util.go
@@ -1,17 +1,10 @@
 package server
 
 import (
-	"encoding/json"
-	"heckel.io/ntfy/util"
 	"net/http"
 	"strings"
 )
 
-const (
-	actionIDLength = 10
-	actionsMax     = 3
-)
-
 func readBoolParam(r *http.Request, defaultValue bool, names ...string) bool {
 	value := strings.ToLower(readParam(r, names...))
 	if value == "" {
@@ -47,103 +40,3 @@ func readQueryParam(r *http.Request, names ...string) string {
 	}
 	return ""
 }
-
-func parseActions(s string) (actions []*action, err error) {
-	// Parse JSON or simple format
-	s = strings.TrimSpace(s)
-	if strings.HasPrefix(s, "[") {
-		actions, err = parseActionsFromJSON(s)
-	} else {
-		actions, err = parseActionsFromSimple(s)
-	}
-	if err != nil {
-		return nil, err
-	}
-
-	// Add ID field, ensure correct uppercase/lowercase
-	for i := range actions {
-		actions[i].ID = util.RandomString(actionIDLength)
-		actions[i].Action = strings.ToLower(actions[i].Action)
-		actions[i].Method = strings.ToUpper(actions[i].Method)
-	}
-
-	// Validate
-	if len(actions) > actionsMax {
-		return nil, wrapErrHTTP(errHTTPBadRequestActionsInvalid, "only %d actions allowed", actionsMax)
-	}
-	for _, action := range actions {
-		if !util.InStringList([]string{"view", "broadcast", "http"}, action.Action) {
-			return nil, wrapErrHTTP(errHTTPBadRequestActionsInvalid, "action '%s' unknown", action.Action)
-		} else if action.Label == "" {
-			return nil, wrapErrHTTP(errHTTPBadRequestActionsInvalid, "parameter 'label' is required")
-		} else if util.InStringList([]string{"view", "http"}, action.Action) && action.URL == "" {
-			return nil, wrapErrHTTP(errHTTPBadRequestActionsInvalid, "parameter 'url' is required for action '%s'", action.Action)
-		} else if action.Action == "http" && util.InStringList([]string{"GET", "HEAD"}, action.Method) && action.Body != "" {
-			return nil, wrapErrHTTP(errHTTPBadRequestActionsInvalid, "parameter 'body' cannot be set if method is %s", action.Method)
-		}
-	}
-
-	return actions, nil
-}
-
-func parseActionsFromJSON(s string) ([]*action, error) {
-	actions := make([]*action, 0)
-	if err := json.Unmarshal([]byte(s), &actions); err != nil {
-		return nil, err
-	}
-	return actions, nil
-}
-
-func parseActionsFromSimple(s string) ([]*action, error) {
-	actions := make([]*action, 0)
-	rawActions := util.SplitNoEmpty(s, ";")
-	for _, rawAction := range rawActions {
-		newAction := &action{
-			Headers: make(map[string]string),
-			Extras:  make(map[string]string),
-		}
-		parts := util.SplitNoEmpty(rawAction, ",")
-		if len(parts) < 3 {
-			return nil, wrapErrHTTP(errHTTPBadRequestActionsInvalid, "action requires at least keys 'action', 'label' and one parameter: %s", rawAction)
-		}
-		for i, part := range parts {
-			key, value := util.SplitKV(part, "=")
-			if key == "" && i == 0 {
-				newAction.Action = value
-			} else if key == "" && i == 1 {
-				newAction.Label = value
-			} else if key == "" && util.InStringList([]string{"view", "http"}, newAction.Action) && i == 2 {
-				newAction.URL = value
-			} else if strings.HasPrefix(key, "headers.") {
-				newAction.Headers[strings.TrimPrefix(key, "headers.")] = value
-			} else if strings.HasPrefix(key, "extras.") {
-				newAction.Extras[strings.TrimPrefix(key, "extras.")] = value
-			} else if key != "" {
-				switch strings.ToLower(key) {
-				case "action":
-					newAction.Action = value
-				case "label":
-					newAction.Label = value
-				case "clear":
-					lvalue := strings.ToLower(value)
-					if !util.InStringList([]string{"true", "yes", "1", "false", "no", "0"}, lvalue) {
-						return nil, wrapErrHTTP(errHTTPBadRequestActionsInvalid, "'clear=%s' not allowed", value)
-					}
-					newAction.Clear = lvalue == "true" || lvalue == "yes" || lvalue == "1"
-				case "url":
-					newAction.URL = value
-				case "method":
-					newAction.Method = value
-				case "body":
-					newAction.Body = value
-				default:
-					return nil, wrapErrHTTP(errHTTPBadRequestActionsInvalid, "key '%s' unknown", key)
-				}
-			} else {
-				return nil, wrapErrHTTP(errHTTPBadRequestActionsInvalid, "unknown term '%s'", part)
-			}
-		}
-		actions = append(actions, newAction)
-	}
-	return actions, nil
-}
diff --git a/server/util_test.go b/server/util_test.go
index 9386cd84..63bc6b40 100644
--- a/server/util_test.go
+++ b/server/util_test.go
@@ -27,56 +27,3 @@ func TestReadBoolParam(t *testing.T) {
 	require.Equal(t, false, up)
 	require.Equal(t, true, firebase)
 }
-
-func TestParseActions(t *testing.T) {
-	actions, err := parseActions("[]")
-	require.Nil(t, err)
-	require.Empty(t, actions)
-
-	actions, err = parseActions("action=http, label=Open door, url=https://door.lan/open; view, Show portal, https://door.lan")
-	require.Nil(t, err)
-	require.Equal(t, 2, len(actions))
-	require.Equal(t, "http", actions[0].Action)
-	require.Equal(t, "Open door", actions[0].Label)
-	require.Equal(t, "https://door.lan/open", actions[0].URL)
-	require.Equal(t, "view", actions[1].Action)
-	require.Equal(t, "Show portal", actions[1].Label)
-	require.Equal(t, "https://door.lan", actions[1].URL)
-
-	actions, err = parseActions(`[{"action":"http","label":"Open door","url":"https://door.lan/open"}, {"action":"view","label":"Show portal","url":"https://door.lan"}]`)
-	require.Nil(t, err)
-	require.Equal(t, 2, len(actions))
-	require.Equal(t, "http", actions[0].Action)
-	require.Equal(t, "Open door", actions[0].Label)
-	require.Equal(t, "https://door.lan/open", actions[0].URL)
-	require.Equal(t, "view", actions[1].Action)
-	require.Equal(t, "Show portal", actions[1].Label)
-	require.Equal(t, "https://door.lan", actions[1].URL)
-
-	actions, err = parseActions("action=http, label=Open door, url=https://door.lan/open, body=this is a body, method=PUT")
-	require.Nil(t, err)
-	require.Equal(t, 1, len(actions))
-	require.Equal(t, "http", actions[0].Action)
-	require.Equal(t, "Open door", actions[0].Label)
-	require.Equal(t, "https://door.lan/open", actions[0].URL)
-	require.Equal(t, "PUT", actions[0].Method)
-	require.Equal(t, "this is a body", actions[0].Body)
-
-	actions, err = parseActions("action=broadcast, label=Do a thing, extras.command=some command, extras.some_param=a parameter")
-	require.Nil(t, err)
-	require.Equal(t, 1, len(actions))
-	require.Equal(t, "broadcast", actions[0].Action)
-	require.Equal(t, "Do a thing", actions[0].Label)
-	require.Equal(t, 2, len(actions[0].Extras))
-	require.Equal(t, "some command", actions[0].Extras["command"])
-	require.Equal(t, "a parameter", actions[0].Extras["some_param"])
-
-	actions, err = parseActions("action=http, label=Send request, url=http://example.com, method=GET, headers.Content-Type=application/json, headers.Authorization=Basic sdasffsf")
-	require.Nil(t, err)
-	require.Equal(t, 1, len(actions))
-	require.Equal(t, "http", actions[0].Action)
-	require.Equal(t, "Send request", actions[0].Label)
-	require.Equal(t, 2, len(actions[0].Headers))
-	require.Equal(t, "application/json", actions[0].Headers["Content-Type"])
-	require.Equal(t, "Basic sdasffsf", actions[0].Headers["Authorization"])
-}