# Webhooks

Source: https://docs.gryt.chat/docs/build/webhooks

Post messages and cards into a Gryt channel with one HTTP request, and try a card before you send it.

A webhook is a URL that posts into one channel. Anything that can send an HTTP
request can use it: a CI job, a cron script, a monitoring tool. There's no SDK,
no login and no socket to keep open. You POST some JSON and a message shows up.

A webhook can only post. It can't read the channel, reply to anyone or react to
messages. If you need any of that, write a [bot](https://docs.gryt.chat/docs/build/bots) instead.

## Make one

You need the **Manage webhooks** permission on the server.

1. Open the server menu (the three dots next to the server name) and pick **Server settings**
2. Go to **Webhooks** and press **Create webhook**
3. Give it a name, pick the channel it posts to, and click the avatar to give it a picture
4. Use the copy button to copy its URL

<img alt="The Webhooks tab in server settings, with one webhook called New Webhook posting to #General" src="__img0" />

Name, channel and picture save as you change them. The trash button deletes the
webhook, and its URL stops working straight away.

## The URL

```text
https://<your server>/api/webhooks/<webhook id>/<token>
```

The token is the whole of the security. Anyone who has the URL can post as the
webhook, so keep it out of public repositories and logs. There's no way to
rotate a token. If a URL leaks, delete the webhook and make a new one.

## Send a message

```bash
curl -X POST 'https://gryt.example.com/api/webhooks/WEBHOOK_ID/TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{"text": "Hello from curl"}'
```

That's a whole message. `text` takes the same markdown as the chat box.
Add `cards` for something with a title, colour, fields and pictures:

```bash
curl -X POST 'https://gryt.example.com/api/webhooks/WEBHOOK_ID/TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{
    "display_name": "Backups",
    "cards": [{
      "title": "Backup finished",
      "description": "All 3 volumes copied in 2m 41s.",
      "color": "#3fb27f"
    }]
  }'
```

The server answers once the message is posted:

```json
{
  "message_id": "7999e063-ccdb-46d3-8cd3-b1decc35dfd6",
  "conversation_id": "general",
  "warnings": []
}
```

## Try a card

Edit the JSON and the card on the right redraws. It's drawn with the same
`WebhookCard` component from [`@gryt/ui`](https://docs.gryt.chat/docs/build/ui) that the app uses,
and the checks under the editor follow the server's rules, so a payload that
passes here passes there. **Copy as curl** gives you a command with a
placeholder URL to swap for yours.

_The interactive card preview is on the web version of this page._

Two things differ from the app. The preview loads pictures straight from the
URLs you type, from your own browser. Gryt doesn't: the server downloads them
once when the message is posted (see [Pictures](#pictures)). And the preview
only does bold, italics, strikethrough, inline code and links. The app's
markdown also does code blocks, lists, emoji and mentions.

## Payload

This reference is read from the server's own OpenAPI document,
[`openapi/webhooks.json`](https://github.com/Gryt-chat/server/blob/main/openapi/webhooks.json),
which is generated from the schema the route checks payloads with. A check in
CI fails when this page and that file disagree.

### Message

| Key | Type | Required | Description |
|---|---|---|---|
| `text` | string | no | Markdown. Required unless there are cards. The only part of a message whose mentions notify. Up to 4,000 characters. |
| `display_name` | string | no | Posts under this name instead of the webhook's. Cut to 64 characters. |
| `avatar_url` | URL | no | Posts with this picture instead of the webhook's. Downloaded once and resized to 256 px, like any other avatar. Up to 2,048 characters. |
| `cards` | Card[] | no | Up to 10. |

A message needs `text`, at least one card, or both. `display_name` and
`avatar_url` only change this one message. The webhook's own name and picture
stay as they are.

### Card

| Key | Type | Required | Description |
|---|---|---|---|
| `title` | string | no | Plain text. Up to 256 characters. Can't be empty. |
| `url` | URL | no | Makes the title a link. Up to 2,048 characters. |
| `description` | string | no | Markdown. Mentions don't notify anyone. Up to 4,000 characters. Can't be empty. |
| `color` | string \| integer | no | The card's colour, as `#rrggbb` or an integer from 0 to 16777215. |
| `author` | Author | no |  |
| `fields` | Field[] | no | Up to 25. |
| `image_url` | URL | no | An http or https URL to a PNG, JPEG, WebP or GIF. The server fetches it once, when the message is posted, and stores a copy. Private and local addresses are refused. Up to 2,048 characters. |
| `thumbnail_url` | URL | no | An http or https URL to a PNG, JPEG, WebP or GIF. The server fetches it once, when the message is posted, and stores a copy. Private and local addresses are refused. Up to 2,048 characters. |
| `footer` | Footer | no |  |
| `timestamp` | timestamp | no | ISO 8601, with a time zone. |

A card needs at least one of `title`, `description`, `fields`, `image_url` or
`author`. The title, description, author name, footer text and every field's
name and value count towards a limit of 6000 characters, added up across all
the cards in the message.

Timestamps show in each reader's own time zone.

### Author

| Key | Type | Required | Description |
|---|---|---|---|
| `name` | string | yes | Plain text. Up to 256 characters. Can't be empty. |
| `url` | URL | no | An http or https URL. Up to 2,048 characters. |
| `icon_url` | URL | no | An http or https URL to a PNG, JPEG, WebP or GIF. The server fetches it once, when the message is posted, and stores a copy. Private and local addresses are refused. Up to 2,048 characters. |

### Field

| Key | Type | Required | Description |
|---|---|---|---|
| `name` | string | yes | Plain text. Up to 256 characters. Can't be empty. |
| `value` | string | yes | Markdown. Up to 1,024 characters. Can't be empty. |
| `inline` | boolean | no | Inline fields share a row. Default `false`. |

Inline fields sit side by side as long as there's room. A field that isn't
inline gets a row to itself.

### Footer

| Key | Type | Required | Description |
|---|---|---|---|
| `text` | string | yes | Plain text. Up to 2,048 characters. Can't be empty. |
| `icon_url` | URL | no | An http or https URL to a PNG, JPEG, WebP or GIF. The server fetches it once, when the message is posted, and stores a copy. Private and local addresses are refused. Up to 2,048 characters. |

## Pictures

`avatar_url`, `image_url`, `thumbnail_url` and both `icon_url`s are downloaded
by the Gryt server when the message is posted. It stores a copy and the message
points at that copy. People reading the channel load the picture from your Gryt
server and never contact the host it came from, so that host can't see who
read the message or when.

That also means the picture is fixed at post time. Changing the file at the
original URL afterwards changes nothing in Gryt.

* PNG, JPEG, WebP and GIF. SVG is refused. The server checks the file's bytes,
  not its name or `Content-Type`
* Up to 8 MB for `image_url` and `thumbnail_url`, 1 MB for `avatar_url` and the
  icons, and 32 MB for all the pictures in one message
* 12 seconds to download all of them
* Private and local addresses are refused, and so is a chain of more than 5
  redirects
* The same URL used twice in a message is downloaded once
* `avatar_url` is resized to 256 px, the same as any other avatar. Card pictures
  keep their size

A picture that fails doesn't stop the message. It posts without that picture,
and the reply lists what went wrong under `warnings`. Because of the downloads,
a message with pictures can take a few seconds to answer.

## Mentions

Only `text` can notify anyone. A mention is a markdown link to `mention:` and
the member's ID:

```json
{ "text": "Deploy failed, [@Sivert](mention:user_166ad0aa-aef6-494d-8475-9c842fbc29b2) can you look?" }
```

To get an ID, right-click the member in the member list and pick **Copy ID**.

Mentions inside cards draw like mentions but never notify, so a card listing
ten commit authors doesn't ping all ten.

## Older clients

A client that can't draw cards shows a single line instead. When a message has
cards and no `text`, the server writes that line itself: the first card's
title, or its author's name if it has no title, plus a count like "(+2 more)"
when there are more cards. When the first card has neither, it says "Posted a
card", or "Posted 3 cards".

Mentions and links are broken on purpose in that line, so it can't ping anyone
or unfurl. Clients that do draw cards hide it. If you send `text` as well,
older clients show your `text` and nothing else.

## When it goes wrong

A refused payload posts nothing. Every problem comes back at once, up to 20,
each with a `path` into your JSON:

```json
{
  "error": "invalid_payload",
  "message": "The payload has 3 problems.",
  "problems": [
    { "path": "cards[0].color", "code": "invalid_color", "message": "Use #rrggbb or an integer from 0 to 16777215." },
    { "path": "cards[0].timestamp", "code": "invalid_timestamp", "message": "Invalid ISO datetime" },
    { "path": "cards[0]", "code": "empty_card", "message": "A card needs a title, description, fields, image_url or author." }
  ]
}
```

`limit` is there too when the problem is a limit. Match on `code`, since
`message` is for people and can change.

| Code | Meaning |
|---|---|
| `required` | Missing, or empty once spaces are trimmed off. |
| `wrong_type` | The wrong kind of value, like a number where text belongs. |
| `too_long` | Over the character limit in `limit`. |
| `too_many` | More cards or fields than `limit` allows. |
| `invalid_url` | Not an http or https URL. |
| `invalid_color` | Not `#rrggbb`, and not a whole number from 0 to 16777215. |
| `invalid_timestamp` | Not ISO 8601 with a time zone. `2026-09-15T07:42:00Z` works, `2026-09-15` doesn't. |
| `empty_card` | The card has no title, description, fields, image_url or author. |
| `total_too_long` | All the cards' text together is over 6000 characters. |
| `invalid_format` | A string in a shape the server doesn't accept. |
| `invalid` | Anything else the server refused. |

Some answers have no `problems` list. The first two are from before cards
existed, and they come back instead of `invalid_payload` when they're the only
problem:

| Status | Body                                                 | When                                    |
| ------ | ---------------------------------------------------- | --------------------------------------- |
| 400    | `{"error": "empty_message"}`                         | No `text` (or only spaces) and no cards |
| 400    | `{"error": "message_too_long"}`                      | `text` is over 4,000 characters         |
| 400    | `{"error": "no_channel"}`                            | The webhook has no channel set          |
| 400    | `{"error": "invalid_json"}`                          | The body isn't valid JSON               |
| 404    | `{"error": "not_found"}`                             | No webhook with that ID and token       |
| 413    | `{"error": "body_too_large"}`                        | The body is over 256 KB                 |
| 429    | `{"error": "rate_limited", "retry_after_ms": 59988}` | See [Rate limits](#rate-limits)         |

Older servers answer bad JSON with a `500` and `"error": "internal_error"`
instead, and let bodies up to 2 MB through before doing the same. If you have to
handle those, treat a 5xx as "check the request, then try again later".

### Warnings

A message that posts can still come back with `warnings`:

```json
{
  "message_id": "7999e063-ccdb-46d3-8cd3-b1decc35dfd6",
  "conversation_id": "general",
  "warnings": [
    { "path": "embeds", "code": "unknown_key", "message": "embeds isn't part of a webhook message and was ignored." },
    { "path": "cards[0].image_url", "code": "blocked", "message": "That address isn't allowed, so the picture was left out." }
  ]
}
```

Keys Gryt doesn't know are ignored rather than refused, so a payload written for
another chat app mostly works. Log `warnings` from anything automated, or a
missing picture goes unnoticed.

| Code | Meaning |
|---|---|
| `unknown_key` | A key Gryt doesn't know. It was ignored and the rest posted. |
| `blocked` | The picture's address is private or local, or it redirected more than 5 times. |
| `fetch_failed` | The picture's host answered with an error, or couldn't be reached. |
| `too_large` | Over 8 MB for an image or thumbnail, or over 1 MB for an icon or avatar. |
| `timeout` | The pictures weren't all downloaded within 12 seconds. |
| `unsupported_type` | Not a PNG, JPEG, WebP or GIF. SVG is refused. The server looks at the bytes, not the file name. |
| `invalid_image` | The file claimed to be an image but couldn't be decoded. |
| `media_budget` | The message's pictures went over 32 MB together. |
| `store_failed` | The server couldn't save the picture. |

## Rate limits

Each webhook gets a burst of 15 requests, then about one every 2 seconds, or 30
a minute. Go over and that webhook gets `429` for 60 seconds, with
`retry_after_ms` saying how long is left.

It counts every request to the webhook's URL, including refused payloads, and
it's per webhook rather than per caller, so two scripts sharing a URL share the
limit. Bad JSON and bodies over 256 KB are turned away before they're counted. The counter lives in the server's memory and resets when it restarts.
[Rate limiting](https://docs.gryt.chat/docs/host/rate-limiting) has the server's other limits.

## Recipes

### Deploy notice from GitHub Actions

Save the webhook URL as a repository secret called `GRYT_WEBHOOK_URL`, then add
a step at the end of the deploy job:

```yaml
- name: Tell Gryt
  if: always()
  env:
    GRYT_WEBHOOK_URL: ${{ secrets.GRYT_WEBHOOK_URL }}
    STATUS: ${{ job.status }}
  run: |
    if [ "$STATUS" = "success" ]; then color="#3fb27f"; else color="#e5484d"; fi
    jq -n \
      --arg status "$STATUS" --arg color "$color" \
      --arg repo "$GITHUB_REPOSITORY" --arg sha "${GITHUB_SHA::7}" \
      --arg actor "$GITHUB_ACTOR" \
      --arg url "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \
      '{
        display_name: "Deploys",
        cards: [{
          title: "Deploy \($status): \($repo)",
          url: $url,
          color: $color,
          fields: [
            { name: "Commit", value: $sha, inline: true },
            { name: "By", value: $actor, inline: true }
          ],
          timestamp: (now | todate)
        }]
      }' |
    curl -fsS -X POST "$GRYT_WEBHOOK_URL" \
      -H 'Content-Type: application/json' --data-binary @-
```

`jq` is already on GitHub's runners. It builds the JSON for you, so a quote in
a name can't break the payload.

### Uptime alert from cron

A script that checks a URL and posts only when the answer changes, so a site
that's down all night sends two messages instead of 500:

```bash
#!/bin/sh
# Run from cron every minute: * * * * * /usr/local/bin/check-api.sh
WEBHOOK_URL="https://gryt.example.com/api/webhooks/WEBHOOK_ID/TOKEN"
TARGET="https://api.example.com/health"
STATE=/var/tmp/check-api.state

code=$(curl -s -o /dev/null -m 10 -w '%{http_code}' "$TARGET")
[ "$code" = "200" ] && now=up || now=down
[ "$(cat "$STATE" 2>/dev/null)" = "$now" ] && exit 0
echo "$now" > "$STATE"

if [ "$now" = "down" ]; then
  title="$TARGET is down"; color="#e5484d"
else
  title="$TARGET is back"; color="#3fb27f"
fi

jq -n --arg title "$title" --arg color "$color" --arg code "$code" '{
  display_name: "Uptime",
  cards: [{
    title: $title,
    color: $color,
    fields: [{ name: "Status", value: $code, inline: true }],
    timestamp: (now | todate)
  }]
}' | curl -fsS -X POST "$WEBHOOK_URL" -H 'Content-Type: application/json' --data-binary @-
```

### Plain text from any script

No `jq`, no cards. Fine for a quick "it's done":

```bash
curl -fsS -X POST "$WEBHOOK_URL" \
  -H 'Content-Type: application/json' \
  -d "{\"text\": \"Backup on $(hostname) finished at $(date -u +%H:%M) UTC\"}"
```

Keep anything you didn't write yourself out of a string like that. A file name
with a quote in it breaks the JSON. Use `jq -n --arg` like the recipes above
when the text comes from somewhere else.

## Managing webhooks over the API

The Webhooks tab uses these, and so can you, with a member's access token that
has **Manage webhooks**. They're listed in [Server API](https://docs.gryt.chat/docs/build/server-api#webhooks),
and the request and response shapes are in the same
[`openapi/webhooks.json`](https://github.com/Gryt-chat/server/blob/main/openapi/webhooks.json).
