Formats
GGspeak reads four formats. The first three are detected from the body. Better Stack is picked only by the /betterstack suffix on the URL. Any format can be forced with a suffix (/discord, /slack, /betterstack).
| Format | Recognized by | Becomes |
|---|---|---|
| GGspeak | card or key at the top level |
A native card |
| Discord | content or embeds |
The 1st embed becomes a card; the other embeds become markdown in the text |
| Slack | text, attachments or blocks |
Markdown text; the 1st attachment with a color sets the card state |
| Better Stack | The /betterstack suffix |
An incident card whose key is the incident id |
With a suffix (/discord, /slack, /betterstack), the body must have that format’s fields. On the bare URL, detection goes GGspeak, then Discord, then Slack; if none matches, the response is 422 with payload_unrecognized.
In every format, mentions in the payload turn into plain text: @everyone, @here, <@id>, <@&id> and GGspeak’s mention tokens are escaped before anything is stored. The only thing that calls anyone is the webhook’s role (Calling a role).
GGspeak
Section titled “GGspeak”The native format, with everything the card can do. It’s the only one that uses key and state directly. A markdown text is only read as GGspeak together with card or key; a body with only text is read as Slack.
{ "key": "deploy-master-4856e7ac", "text": "API deploy finished.", "card": { "title": "API deploy: master", "state": "ok", "body": "api: create, list, pause, delete and regenerate webhook link", "url": "https://github.com/ggspeak/ggspeak/actions/runs/11800234567", "fields": [ { "name": "Commit", "value": "4856e7ac" }, { "name": "Author", "value": "rafael" }, { "name": "Duration", "value": "3 min 12 s" } ] }}| Field | What it is |
|---|---|
text |
Optional markdown text. Up to 2,000 characters: above that the API rejects it instead of cutting it. |
card |
The card, optional. text or card is required. |
key |
Optional. Up to 128 visible ASCII characters, no spaces. Sending the same key again updates the same card: see Card and key. The key only works together with card: with only text, every POST creates a new message. |
username |
Optional, up to 32 characters. The name shown on that message instead of the webhook’s name. |
card.title |
Required. Up to 256 characters. |
card.state |
alert, warn, ok or info. Defaults to info. |
card.body |
Markdown, up to 1,000 characters. |
card.url |
An http:// or https:// link up to 512 characters. Anything else is dropped. |
card.fields |
Up to 6 { "name", "value" } fields: name up to 64 characters, value up to 256. Fields beyond 6 are ignored. |
Card text over the limit is cut with “…”. A wrong type (a state outside the list, fields that isn’t a list, a field without name or value) comes back as payload_invalid, with the field named in the message.
State is always shown with an icon and a word: alert reads as firing, warn as warning, ok as resolved and info as info. The color comes from the app’s theme, not from the payload.
Discord
Section titled “Discord”If a tool already posts to a Discord webhook, change only the URL. The example below is what Grafana’s Discord contact point sends.
{ "username": "Grafana", "content": "", "embeds": [ { "title": "[FIRING:1] HighCPU (sp-1 api)", "url": "https://grafana.ggspeak.app/alerting/list?view=list&search=HighCPU", "color": 15158332, "description": "**Firing**\n\nValue: A=92.4\nLabels:\n - alertname = HighCPU\n - host = sp-1\n - job = api\nAnnotations:\n - summary = CPU above 90% for 5 minutes\nSource: https://grafana.ggspeak.app/alerting/grafana/cpu-high/view\nSilence: https://grafana.ggspeak.app/alerting/silence/new?alertmanager=grafana&matcher=alertname%3DHighCPU", "footer": { "text": "Grafana v11.3.0", "icon_url": "https://grafana.com/static/assets/img/fav32.png" } } ]}- The 1st embed becomes the card:
title(orauthor.name, or “Aviso”),description,url,color,fields(nameandvalue),footer.textandtimestamp. The footer and the date go at the end of the card body. - Later embeds become markdown at the end of the text: the title in bold with the description below it.
contentbecomes the message text. A body with neither acontentnor anembedsfield isn’t Discord: with the/discordsuffix, or when no other format’s field is present (on the bare URL, detection goes GGspeak, then Discord, then Slack), the response ispayload_unrecognized. If the field is there but empty ("content": ""and no embeds), it ispayload_invalid.usernameis the name for that message (up to 32 characters).avatar_url,image,thumbnailandallowed_mentionsare ignored. GGspeak never fetches an image or avatar from a third-party URL, so no IP address leaks that way.- Discord bodies have no
key: every POST is a new message.
Discord color becomes a state
Section titled “Discord color becomes a state”Discord’s color is a number (RGB). Its hue decides the card state:
| Color | State |
|---|---|
| Red and magenta | alert |
| Orange and yellow | warn |
| Green | ok |
| Everything else (blue, cyan, purple) and no color | info |
Grays and very washed-out or very dark colors fall back to info. The red 15158332 in the example becomes alert; 3066993 (green) becomes ok.
A Slack webhook body: text, attachments and blocks.
{ "text": "*Daily backup* finished with a warning <https://status.ggspeak.app/backup|details>", "attachments": [ { "fallback": "Daily backup: 2 tables above the expected size", "color": "warning", "title": "Daily backup", "title_link": "https://status.ggspeak.app/backup", "text": "2 tables above the expected size: `channel_messages` and `hook_deliveries`.", "fields": [ { "title": "Size", "value": "1.8 GB", "short": true }, { "title": "Duration", "value": "42 s", "short": true } ], "footer": "pg_dump", "ts": 1793700000 } ]}textbecomes the message text, converted to markdown:*bold*becomes**bold**,<url|label>becomes a link, and&,<and>turn back into the character.- The first attachment that has a
colorbecomes the card.goodisok,warningiswarn,dangerisalert, and a hex value (#e5484d) follows the same hue rule as Discord. The title comes fromtitle(orfallback), the body fromtext, the link fromtitle_linkand the fields fromfields(titleandvalue). - An attachment without
coloris ignored. If you want the card, send acolor. - A body with none of the
text,attachmentsorblocksfields isn’t Slack: with the/slacksuffix, or when no other format’s field is present, the response ispayload_unrecognized. With a field present but nothing to show, it ispayload_invalid. sectionandheaderblocks become text (headerin bold). Other block types are ignored.- Slack bodies have no
key: every POST is a new message.
Better Stack
Section titled “Better Stack”Better Stack doesn’t send a state: its default body is the incident. Use the URL with the /betterstack suffix:
https://ggspeak.app/hooks/{id}/{token}/betterstack{ "data": { "id": "567890123", "type": "incident", "attributes": { "name": "API", "url": "https://uptime.betterstack.com/team/123456/incidents/567890123", "http_method": "get", "cause": "Status 503", "incident_group_id": null, "started_at": "2026-11-03T14:02:00.000Z", "acknowledged_at": null, "acknowledged_by": null, "resolved_at": null, "resolved_by": null, "status": "Started", "team_name": "GGspeak", "response_content": "", "response_options": null, "regions": ["us", "eu"], "response_url": null, "screenshot_url": null, "escalation_policy_id": null, "call": true, "sms": true, "email": true, "push": true }, "relationships": { "monitor": { "data": { "id": "2345678", "type": "monitor" } } } }}| What GGspeak reads | Becomes |
|---|---|
data.id |
The card’s key: the whole incident lifecycle lands on one card |
attributes.resolved_at set |
ok |
attributes.acknowledged_at set (and no resolved_at) |
warn |
| neither | alert |
attributes.name |
Title (“Incidente” if missing) |
attributes.cause |
Body |
attributes.url |
The card’s link |
Everything else in the body is ignored. The same incident once acknowledged has acknowledged_at and acknowledged_by, and once resolved it also has resolved_at and resolved_by. The result is one card that goes from firing to warning to resolved without flooding the channel.