Post messages with a webhook
A webhook is a URL that posts into your community. Send it JSON from anything that can make an HTTP request, such as CI, monitoring, a cron job or a script, and the message appears in the channel.
What you can do
- Post text or a cardPlain text, or a card with a title, a colour, markdown, fields and images.
- Attach filesUp to five files per message: logs, reports, screenshots.
- Add buttonsLinks, or buttons that change the card or reach your service.
- Change it laterThe response carries a callback URL to update or delete the message.
In the app
One request from CI, one card in #deploys. The name at the top is the name you gave the webhook.
Quick start
Create the webhook
In the desktop app, open your community's Manage Server → Webhooks, create a webhook, choose the channels it may post in and copy the URL for a channel. It has this shape:
urlhttps://mss.gs/api/v1/webhook/{webhook_guid}/{token}/{channel_guid}Send a message
Sign the JSON with the webhook's secret and POST it. A webhook made in the desktop app always has one: copy it from Webhook Secret in the webhook's settings.
bashBODY='{"content": "Build #1847 passed on main"}' SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$MSSGS_WEBHOOK_SECRET" | sed 's/^.* //') curl -X POST "$MSSGS_WEBHOOK_URL" \ -H "Content-Type: application/json" \ -H "X-Mssgs-Signature: sha256=$SIG" \ -d "$BODY"Read the answer
The response gives you the message id, and a
callback_urlto change the message later.json{ "success": true, "message_id": "aZZ1a2b-...", "callback_url": "https://mss.gs/api/v1/trigger-callback/8f14e45f-...", "stream_url": "https://mss.gs/api/v1/instant/6b1e...c0?token=a1b2c3d4-..." }
What you can send
A message is either the short form (text with a title and a colour) or a full card, and either can carry buttons and files. The name at the top of the card is always the webhook's own name. In the webhook's settings you also decide whether it may post images and mention people.
Short form
Enough for most alerts.
{
"content": "Server backup completed at 03:00 UTC",
"color": "green",
"title": "Backup Bot"
}| Field | Type | What it does |
|---|---|---|
content | string | The message text. Required unless you send a card or files. |
color | string | blue (default), green, orange, red, yellow or purple. |
title | string | A title above the text. Defaults to the webhook's name. |
A full card
Send a message_container for a card with a linked title, a subtitle, markdown, fields and images. Through a webhook a card takes type, color, title, title_url, sub_title, description, fields, avatar_url, image_url, image_base64, images and the loader fields. The status pill, badge, diff stats and folded reasoning are for command replies. Every field is on message cards.
{
"message_container": {
"type": "embed_message",
"color": "green",
"title": "Build #1847 passed",
"title_url": "https://ci.example.com/builds/1847",
"description": "All 212 tests green on **main**.",
"fields": [
{ "field": "Duration", "value": "2m 34s" },
{ "field": "Commit", "value": "1a2b3c4" }
]
}
}Buttons
Add an actions array to put buttons under the message. How they work is on buttons.
Files
Post real files with a message: a log, a report, a screenshot. They show like any other attachment, as a download row or inline for images, video and audio. A message with only files is fine: leave out content and the card.
{
"message_container": {
"color": "orange",
"title": "Log dump: ios",
"description": "DMs stopped arriving after switching networks"
},
"attachments": [
{
"name": "mssgs-logs-20260803-141205.log",
"content_base64": "MjAyNi0wOC0wMyAxNDoxMjowNSBbV1NdIGNvbm5lY3RlZAo=",
"mime_type": "text/plain"
}
]
}| Field | Type | What it does |
|---|---|---|
name | string | The file name it downloads as. Required. A path is reduced to its last part. |
content_base64 | string | The file's bytes as base64, raw or as a data: URI. mssgs stores the file and keeps only a link on the message. |
mime_type | string | The content type of content_base64. Defaults to text/plain. |
url | string | A file already hosted on mssgs: a /static/... path or an https://mss.gs/... URL. |
content_base64 or url per file. Both, or neither, is an error.| Limit | Value |
|---|---|
| Files per message | 5 |
| Size per file, after decoding | 8 MB |
| File name | 200 characters |
| Whole request | About 10 MB. Base64 makes a file a third bigger, so a single file above roughly 7 MB will not fit. |
Why url only takes mssgs addresses
A webhook URL often ends up pasted into other dashboards. A leaked one must not let someone make every member's app fetch a file from a server they picked. If your file lives elsewhere, send it as content_base64 and mssgs hosts it.
A failed upload does not fail the message
Files are checked up front but uploaded afterwards. If an upload fails, that file is left out and the rest of the message still posts, without an error: losing the file beats losing the report. If a file matters, check that it arrived.
A file from the command line
BODY='{"attachments":[{"name":"report.csv","content_base64":"'"$(base64 < report.csv | tr -d '\n')"'","mime_type":"text/csv"}]}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$MSSGS_WEBHOOK_SECRET" | sed 's/^.* //')
printf '%s' "$BODY" | curl -sS -X POST "$MSSGS_WEBHOOK_URL" \
-H 'Content-Type: application/json' \
-H "X-Mssgs-Signature: sha256=$SIG" \
--data-binary @-Signing requests
A webhook with a secret only accepts requests that prove they know it, and a webhook made in the desktop app always has one (Webhook Secret in its settings). Sign the raw request body with HMAC-SHA256 using the secret, and send the lowercase hex digest in the X-Mssgs-Signature header as sha256=<hex>.
import crypto from 'node:crypto';
const body = JSON.stringify({ content: 'Deploy finished' });
const signature = crypto.createHmac('sha256', process.env.MSSGS_WEBHOOK_SECRET)
.update(body)
.digest('hex');
await fetch(process.env.MSSGS_WEBHOOK_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Mssgs-Signature': `sha256=${signature}`
},
body
});401 and {"error": "INVALID_SIGNATURE"}. Only a webhook without a secret, such as one created over MCP without webhook_secret, accepts unsigned requests.GitHub's own X-Hub-Signature-256 header is accepted too, so a GitHub webhook with the same secret works as is. The signature has no timestamp, so it does not stop a captured request from being sent again: the URL stays the secret that matters.
Responses and errors
A message that was posted comes back with its id and a callback_url to update or delete it for 30 minutes.
{
"success": true,
"message_id": "aZZ1a2b-...",
"callback_url": "https://mss.gs/api/v1/trigger-callback/8f14e45f-...",
"stream_url": "https://mss.gs/api/v1/instant/6b1e...c0?token=a1b2c3d4-..."
}error code in the body. Treat any body with an error key as a failure, whatever the status code.HTTP/1.1 200 OK
Content-Type: application/json
{ "error": "ATTACHMENT_TOO_LARGE" }const res = await fetch(webhookUrl, { method: 'POST', headers, body });
const reply = await res.json().catch(() => null);
// A rejected payload still comes back as 200: read the body.
if (!res.ok || (reply && reply.error)) {
throw new Error(`webhook rejected: ${reply?.error ?? res.status}`);
}When the message has buttons, the response also has a stream_url: a live stream of the replies, reactions and button presses on that message, open for 10 minutes, or an hour when you send "sse_event_extended_timeout": true. See live updates.
Status codes
| Status | When |
|---|---|
401 | The webhook has a secret and the signature is missing or wrong. |
403 | This webhook may not post in that channel. |
404 | There is no webhook at this URL. |
413 | The request is too large. |
429 | Too many requests. Slow down and try again. |
502 | The message could not be delivered. Try again. |
Error codes
| Code | Meaning |
|---|---|
MISSING_CONTENT | Nothing to post: no text, no card and no files. |
INVALID_MESSAGE_CONTAINER | message_container is not an object. |
INVALID_MESSAGE_CONTAINER_TYPE | The card type is not embed_message or system_message. |
MISSING_MESSAGE_CONTAINER_DESCRIPTION | A card needs a description, unless it is a loader. |
MISSING_MESSAGE_CONTAINER_LOADER_TEXT | A loader card needs loader_text. |
INVALID_WEBHOOK_BINDING | This webhook may not post in that channel. |
INVALID_SIGNATURE | The signature header is missing or wrong. |
REQUEST_BODY_TOO_LARGE | The request is over the size limit. |
PUBLISH_FAILED | The message could not be delivered. |
INVALID_ACTIONS_FORMAT, INVALID_ACTION_MISSING_FIELDS, DUPLICATE_ACTION_ID, INVALID_TRIGGERS_FORMAT, INVALID_TRIGGER_MISSING_ACTION, INVALID_TRIGGER_ACTION_NOT_ALLOWED | Something is wrong with a button. See buttons. |
File errors
| Code | Meaning |
|---|---|
INVALID_ATTACHMENTS_FORMAT | attachments is not a list, or an entry is not an object. |
TOO_MANY_ATTACHMENTS | More than five files. |
MISSING_ATTACHMENT_NAME | A file has no name. |
INVALID_ATTACHMENT_NAME | The name reduces to nothing usable, such as ... |
MISSING_ATTACHMENT_SOURCE | Neither url nor content_base64. |
AMBIGUOUS_ATTACHMENT_SOURCE | Both url and content_base64. |
INVALID_ATTACHMENT_BASE64 | The base64 does not decode. |
ATTACHMENT_TOO_LARGE | A file is over 8 MB after decoding. |
INVALID_ATTACHMENT_URL | The url is not an mssgs address. |
Limits
| Limit | Value |
|---|---|
| Request size | About 10 MB |
| Files per message | 5, of up to 8 MB each |
| Card description | Up to 50,000 bytes. Past 1,000 bytes, members see the start and a Show more button. |
| Updating the message afterwards | 30 minutes, through callback_url |
| Live stream of a message with buttons | 10 minutes, or an hour on request |
Requests are rate-limited. When you get a 429, wait before you send again, and group alerts that arrive in bursts into one message.
GitHub, UniFi and App Store Connect
Point one of these services at a webhook URL and mssgs recognises it and posts a proper card, with no payload to write. See integrations. These answer with {"success": true} and no callback URL.
| Source | Recognised by | What it posts |
|---|---|---|
| GitHub | The x-github-event header | Pushes, pull requests and reviews, issues and comments, branches and tags, releases. A burst of changes to one issue or pull request is gathered into one card. |
| UniFi Protect | The protect-alarm-manager user agent | Doorbell rings, motion, and people, vehicles or packages detected by your cameras. |
| App Store Connect | Its notification body or the x-apple-signature header | App Store Connect notifications. |