diff --git a/docs/publish.md b/docs/publish.md
index c03cc4a4..63ca82f9 100644
--- a/docs/publish.md
+++ b/docs/publish.md
@@ -1114,6 +1114,7 @@ all the supported fields:
 | `delay`    | -        | *string*                         | `30min`, `9am`                            | Timestamp or duration for delayed delivery                            |
 | `email`    | -        | *e-mail address*                 | `phil@example.com`                        | E-mail address for e-mail notifications                               |
 | `call`     | -        | *phone number or 'yes'*          | `+1222334444` or `yes`                    | Phone number to use for [voice call](#phone-calls)                    |
+| `extras`   | -        | *JSON object*                    | `{"customField": "customValue"}`          | Extra key:value pairs will be included in the notification            |
 
 ## Action buttons
 _Supported on:_ :material-android: :material-apple: :material-firefox:
@@ -2662,6 +2663,123 @@ Here's an example of how it will look on Android:
   <figcaption>Custom icon from an external URL</figcaption>
 </figure>
 
+## Custom fields
+_Supported on:_ :material-android:
+
+You can send custom key:value pairs that will be included as-is in the notification. This can be helpful if you are
+using ntfy to pass messages between different computer programs or services, for example. Simply pass a stringified
+JSON object in the `X-Extras` header, or include the JSON object in the `extras` key when using [JSON publishing]
+(#publish-as-json). **The JSON object can only be 1 level deep, nesting is not supported**.
+
+Here's an example showing how to send custom fields:
+
+=== "Command line (curl) (JSON)"
+    ```
+    curl ntfy.sh \
+      -d '{
+        "topic": "mytopic",
+        "message": "Disk space is low at 5.1 GB",
+        "title": "Low disk space alert",
+        "tags": ["warning","cd"],
+        "priority": 4,
+        "extras": {"lastChecked": "20230205"}
+      }'
+    ```
+=== "Command line (curl) (Header)"
+    ```
+    curl \
+        -H "Title: Low disk space alert" \
+        -H "Tags: warning,cd" \
+        -H "X-Priority: 4" \
+        -H 'X-Extras: {"lastChecked": "20230205"}' \
+        -d "Disk space is low at 5.1 GB" \
+        ntfy.sh/mytopic
+    ```
+
+=== "HTTP"
+    ``` http
+    POST /mytopic HTTP/1.1
+    Host: ntfy.sh
+    Title: Low disk space alert
+    Tags: warning,cd
+    X-Priority: 4
+    X-Extras: {"lastChecked": "20230205"}
+
+    Disk space is low at 5.1 GB
+    ```
+
+=== "JavaScript"
+    ``` javascript
+    fetch('https://ntfy.sh/mytopic', {
+        method: 'POST',
+        headers: {
+            'Title': 'Low disk space alert',
+            'Tags': 'warning,cd'
+            'X-Priority': '4',
+            'X-Extras': {'lastChecked': '20230205'}
+        },
+        body: "Disk space is low at 5.1 GB"
+    })
+    ```
+
+=== "Go"
+    ``` go
+    req, _ := http.NewRequest("POST", "https://ntfy.sh/mytopic", strings.NewReader("Disk space is low at 5.1 GB"))
+    req.Header.Set("Title", "Low disk space alert")
+    req.Header.Set("Tags", "warning,cd")
+    req.Header.Set("X-Priority", "4")
+    req.Header.Set("X-Extras", `{"lastChecked": "20230205"}`)
+    http.DefaultClient.Do(req)
+    ```
+
+=== "PowerShell"
+    ``` powershell
+    $Request = @{
+      Method = "POST"
+      URI = "https://ntfy.sh"
+      Body = @{
+        Topic    = "mytopic"
+        Title    = "Low disk space alert"
+        Tags     = @("warning", "cd")
+        Priority = 4
+        Message  = "Disk space is low at 5.1 GB"
+        Extras   = ConvertTo-JSON @{
+          lastChecked = "20230205"
+        }
+      }
+      ContentType = "application/json"
+    }
+    Invoke-RestMethod @Request
+    ```
+
+=== "Python"
+    ``` python
+    requests.post("https://ntfy.sh/mytopic",
+        data="Disk space is low at 5.1 GB",
+        headers={
+            "Title": "Low disk space alert",
+            "Tags": "warning,cd",
+            "X-Priority": "4",
+            "X-Extras": '{"lastChecked": "20230205"}'
+        })
+    ```
+
+=== "PHP"
+    ``` php-inline
+    file_get_contents('https://ntfy.sh/mytopic', false, stream_context_create([
+        'http' => [
+        'method' => 'PUT',
+        'header' =>
+            "Content-Type: text/plain\r\n" . // Does not matter
+            "Title: Low disk space alert\r\n" .
+            "Tags: warning,cd\r\n" .
+            "X-Priority: 4\r\n" .
+            "X-Extras: {\"lastChecked\": \"20230205\"}",
+        ],
+        'content' => "Disk space is low at 5.1 GB"
+    ]));
+    ```
+
 ## E-mail notifications
 _Supported on:_ :material-android: :material-apple: :material-firefox:
 
diff --git a/docs/releases.md b/docs/releases.md
index 1e518c76..f8dfd64a 100644
--- a/docs/releases.md
+++ b/docs/releases.md
@@ -1285,6 +1285,10 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
 
 ### ntfy server v2.8.0 (UNRELEASED)
 
+**Features:**
+
+* You can now send custom fields within an `extras` field in a JSON POST/PUT request ([#827](https://github.com/binwiederhier/ntfy/issues/827), thanks to [@tka85](https://github.com/tka85) for reporting and to [@wunter8](https://github.com/wunter8) for implementing)
+
 **Bug fixes + maintenance:**
 
 * Fix ACL issue with topic patterns containing underscores ([#840](https://github.com/binwiederhier/ntfy/issues/840), thanks to [@Joe-0237](https://github.com/Joe-0237) for reporting)
diff --git a/docs/subscribe/api.md b/docs/subscribe/api.md
index 58da9752..a7763f33 100644
--- a/docs/subscribe/api.md
+++ b/docs/subscribe/api.md
@@ -329,6 +329,7 @@ format of the message. It's very straight forward:
 | `click`      | -        | *URL*                                             | `https://example.com`                                 | Website opened when notification is [clicked](../publish.md#click-action)                                                            |
 | `actions`    | -        | *JSON array*                                      | *see [actions buttons](../publish.md#action-buttons)* | [Action buttons](../publish.md#action-buttons) that can be displayed in the notification                                             |
 | `attachment` | -        | *JSON object*                                     | *see below*                                           | Details about an attachment (name, URL, size, ...)                                                                                   |
+| `extras`     | -        | *JSON object*                                     | `{"customField": "customValue"}`                      | Extra key:value pairs provided by the publisher                                                                                      |
 
 **Attachment** (part of the message, see [attachments](../publish.md#attachments) for details):
 
@@ -363,6 +364,9 @@ Here's an example for each message type:
             "expires": 1643946728,
             "url": "https://ntfy.sh/file/sPs71M8A2T.png"
         },
+        "extras": {
+            "customField": "customValue"
+        },
         "title": "Unauthorized access detected",
         "message": "Movement detected in the yard. You better go check"
     }
diff --git a/server/errors.go b/server/errors.go
index 27ba3df0..91ad026f 100644
--- a/server/errors.go
+++ b/server/errors.go
@@ -117,6 +117,7 @@ var (
 	errHTTPBadRequestWebPushSubscriptionInvalid      = &errHTTP{40038, http.StatusBadRequest, "invalid request: web push payload malformed", "", nil}
 	errHTTPBadRequestWebPushEndpointUnknown          = &errHTTP{40039, http.StatusBadRequest, "invalid request: web push endpoint unknown", "", nil}
 	errHTTPBadRequestWebPushTopicCountTooHigh        = &errHTTP{40040, http.StatusBadRequest, "invalid request: too many web push topic subscriptions", "", nil}
+	errHTTPBadRequestExtrasInvalid                   = &errHTTP{40041, http.StatusBadRequest, "invalid request: extras invalid", "", nil}
 	errHTTPNotFound                                  = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil}
 	errHTTPUnauthorized                              = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", nil}
 	errHTTPForbidden                                 = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil}
diff --git a/server/server.go b/server/server.go
index 0ab36524..2256f45c 100644
--- a/server/server.go
+++ b/server/server.go
@@ -1010,6 +1010,14 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
 			return false, false, "", "", false, errHTTPBadRequestActionsInvalid.Wrap(e.Error())
 		}
 	}
+	extrasStr := readParam(r, "x-extras")
+	if extrasStr != "" {
+		extras := make(map[string]string)
+		if err := json.Unmarshal([]byte(extrasStr), &extras); err != nil {
+			return false, false, "", "", false, errHTTPBadRequestExtrasInvalid.Wrap(e.Error())
+		}
+		m.Extras = extras
+	}
 	contentType, markdown := readParam(r, "content-type", "content_type"), readBoolParam(r, false, "x-markdown", "markdown", "md")
 	if markdown || strings.ToLower(contentType) == "text/markdown" {
 		m.ContentType = "text/markdown"
@@ -1808,6 +1816,14 @@ func (s *Server) transformBodyJSON(next handleFunc) handleFunc {
 		if m.Call != "" {
 			r.Header.Set("X-Call", m.Call)
 		}
+		if len(m.Extras) > 0 {
+			extrasStr, err := json.Marshal(m.Extras)
+			if err != nil {
+				return errHTTPBadRequestMessageJSONInvalid
+			}
+			r.Header.Set("X-Extras", string(extrasStr))
+		}
+
 		return next(w, r, v)
 	}
 }
diff --git a/server/server_test.go b/server/server_test.go
index 647268fb..42037980 100644
--- a/server/server_test.go
+++ b/server/server_test.go
@@ -1555,7 +1555,7 @@ func TestServer_PublishAsJSON(t *testing.T) {
 	s := newTestServer(t, newTestConfig(t))
 	body := `{"topic":"mytopic","message":"A message","title":"a title\nwith lines","tags":["tag1","tag 2"],` +
 		`"not-a-thing":"ok", "attach":"http://google.com","filename":"google.pdf", "click":"http://ntfy.sh","priority":4,` +
-		`"icon":"https://ntfy.sh/static/img/ntfy.png", "delay":"30min"}`
+		`"icon":"https://ntfy.sh/static/img/ntfy.png", "delay":"30min", "extras": {"customField":"foo"}}`
 	response := request(t, s, "PUT", "/", body, nil)
 	require.Equal(t, 200, response.Code)
 
@@ -1569,6 +1569,7 @@ func TestServer_PublishAsJSON(t *testing.T) {
 	require.Equal(t, "http://ntfy.sh", m.Click)
 	require.Equal(t, "https://ntfy.sh/static/img/ntfy.png", m.Icon)
 	require.Equal(t, "", m.ContentType)
+	require.Equal(t, map[string]string{"customField": "foo"}, m.Extras)
 
 	require.Equal(t, 4, m.Priority)
 	require.True(t, m.Time > time.Now().Unix()+29*60)
diff --git a/server/types.go b/server/types.go
index eeb566fc..ae99f94b 100644
--- a/server/types.go
+++ b/server/types.go
@@ -25,24 +25,25 @@ const (
 
 // message represents a message published to a topic
 type message struct {
-	ID          string      `json:"id"`                // Random message ID
-	Time        int64       `json:"time"`              // Unix time in seconds
-	Expires     int64       `json:"expires,omitempty"` // Unix time in seconds (not required for open/keepalive)
-	Event       string      `json:"event"`             // One of the above
-	Topic       string      `json:"topic"`
-	Title       string      `json:"title,omitempty"`
-	Message     string      `json:"message,omitempty"`
-	Priority    int         `json:"priority,omitempty"`
-	Tags        []string    `json:"tags,omitempty"`
-	Click       string      `json:"click,omitempty"`
-	Icon        string      `json:"icon,omitempty"`
-	Actions     []*action   `json:"actions,omitempty"`
-	Attachment  *attachment `json:"attachment,omitempty"`
-	PollID      string      `json:"poll_id,omitempty"`
-	ContentType string      `json:"content_type,omitempty"` // text/plain by default (if empty), or text/markdown
-	Encoding    string      `json:"encoding,omitempty"`     // empty for raw UTF-8, or "base64" for encoded bytes
-	Sender      netip.Addr  `json:"-"`                      // IP address of uploader, used for rate limiting
-	User        string      `json:"-"`                      // UserID of the uploader, used to associated attachments
+	ID          string            `json:"id"`                // Random message ID
+	Time        int64             `json:"time"`              // Unix time in seconds
+	Expires     int64             `json:"expires,omitempty"` // Unix time in seconds (not required for open/keepalive)
+	Event       string            `json:"event"`             // One of the above
+	Topic       string            `json:"topic"`
+	Title       string            `json:"title,omitempty"`
+	Message     string            `json:"message,omitempty"`
+	Priority    int               `json:"priority,omitempty"`
+	Tags        []string          `json:"tags,omitempty"`
+	Click       string            `json:"click,omitempty"`
+	Icon        string            `json:"icon,omitempty"`
+	Actions     []*action         `json:"actions,omitempty"`
+	Attachment  *attachment       `json:"attachment,omitempty"`
+	PollID      string            `json:"poll_id,omitempty"`
+	ContentType string            `json:"content_type,omitempty"` // text/plain by default (if empty), or text/markdown
+	Encoding    string            `json:"encoding,omitempty"`     // empty for raw UTF-8, or "base64" for encoded bytes
+	Extras      map[string]string `json:"extras,omitempty"`
+	Sender      netip.Addr        `json:"-"` // IP address of uploader, used for rate limiting
+	User        string            `json:"-"` // UserID of the uploader, used to associated attachments
 }
 
 func (m *message) Context() log.Context {
@@ -92,20 +93,21 @@ func newAction() *action {
 
 // publishMessage is used as input when publishing as JSON
 type publishMessage struct {
-	Topic    string   `json:"topic"`
-	Title    string   `json:"title"`
-	Message  string   `json:"message"`
-	Priority int      `json:"priority"`
-	Tags     []string `json:"tags"`
-	Click    string   `json:"click"`
-	Icon     string   `json:"icon"`
-	Actions  []action `json:"actions"`
-	Attach   string   `json:"attach"`
-	Markdown bool     `json:"markdown"`
-	Filename string   `json:"filename"`
-	Email    string   `json:"email"`
-	Call     string   `json:"call"`
-	Delay    string   `json:"delay"`
+	Topic    string            `json:"topic"`
+	Title    string            `json:"title"`
+	Message  string            `json:"message"`
+	Priority int               `json:"priority"`
+	Tags     []string          `json:"tags"`
+	Click    string            `json:"click"`
+	Icon     string            `json:"icon"`
+	Actions  []action          `json:"actions"`
+	Attach   string            `json:"attach"`
+	Markdown bool              `json:"markdown"`
+	Filename string            `json:"filename"`
+	Email    string            `json:"email"`
+	Call     string            `json:"call"`
+	Delay    string            `json:"delay"`
+	Extras   map[string]string `json:"extras"`
 }
 
 // messageEncoder is a function that knows how to encode a message