Skip to content

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).

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.

Complete example
{
"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.

If a tool already posts to a Discord webhook, change only the URL. The example below is what Grafana’s Discord contact point sends.

Discord body (Grafana)
{
"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 (or author.name, or “Aviso”), description, url, color, fields (name and value), footer.text and timestamp. 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.
  • content becomes the message text. A body with neither a content nor an embeds field isn’t Discord: with the /discord suffix, or when no other format’s field is present (on the bare URL, detection goes GGspeak, then Discord, then Slack), the response is payload_unrecognized. If the field is there but empty ("content": "" and no embeds), it is payload_invalid.
  • username is the name for that message (up to 32 characters).
  • avatar_url, image, thumbnail and allowed_mentions are 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’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.

Slack body
{
"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
}
]
}
  • text becomes the message text, converted to markdown: *bold* becomes **bold**, <url|label> becomes a link, and &amp;, &lt; and &gt; turn back into the character.
  • The first attachment that has a color becomes the card. good is ok, warning is warn, danger is alert, and a hex value (#e5484d) follows the same hue rule as Discord. The title comes from title (or fallback), the body from text, the link from title_link and the fields from fields (title and value).
  • An attachment without color is ignored. If you want the card, send a color.
  • A body with none of the text, attachments or blocks fields isn’t Slack: with the /slack suffix, or when no other format’s field is present, the response is payload_unrecognized. With a field present but nothing to show, it is payload_invalid.
  • section and header blocks become text (header in bold). Other block types are ignored.
  • Slack bodies have no key: every POST is a new message.

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
Incident started
{
"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.