kitazith

Package VersionHex Docs

Description

kitazith is a Gleam library for building Discord incoming webhook payloads safely.

Further documentation can be found at https://hexdocs.pm/kitazith.

Installation

gleam add kitazith

If you also want to send webhook requests as shown in the example below, add an HTTP client as well:

gleam add gleam_http gleam_httpc

Example

See kitazith_example for more.

The payload construction and validation come from kitazith. The request sending in this example uses gleam_http and gleam_httpc.

import gleam/http
import gleam/http/request
import gleam/httpc
import gleam/result

import kitazith/color
import kitazith/embed
import kitazith/poll
import kitazith/webhook/execute

pub fn main() {
  let assert Ok(base_req) = request.to(webhook_url())
  let assert Ok(payload) = build_payload() |> execute.validate
  let req =
    base_req
    |> request.prepend_header("content-type", "application/json")
    |> request.set_method(http.Post)
    |> request.set_body(payload |> execute.to_string)
  use resp <- result.try(httpc.send(req))
  assert resp.status == 204
  Ok(resp)
}

pub fn webhook_url() -> String {
  // Replace this with your own Discord webhook URL.
  "https://discord.com/api/webhooks/YOUR_WEBHOOK_ID/YOUR_WEBHOOK_TOKEN"
}

pub fn build_payload() -> execute.ExecutePayload {
  execute.new()
  |> execute.with_username("A bot")
  |> execute.with_content("Hello from Gleam!")
  |> execute.with_embeds([
    embed.new()
    |> embed.with_title("Release Status")
    |> embed.with_description("The build is green and ready to ship.")
    |> embed.with_color(color.from_rgb(red: 87, green: 242, blue: 135))
    |> embed.with_fields([
      embed.new_field(name: "Status", value: "🟢 Green")
      |> embed.with_field_inline(True),
    ]),
  ])
  |> execute.with_poll(
    poll.new(question: poll.PollQuestion(text: "Ship it?"), answers: [
      poll.PollAnswer(
        poll_media: poll.new_poll_media()
        |> poll.with_poll_media_emoji(
          poll.new_poll_emoji() |> poll.with_poll_emoji_name("✅"),
        )
        |> poll.with_poll_media_text("Yes"),
      ),
      poll.PollAnswer(
        poll_media: poll.new_poll_media()
        |> poll.with_poll_media_emoji(
          poll.new_poll_emoji() |> poll.with_poll_emoji_name("⚠️"),
        )
        |> poll.with_poll_media_text("Need one more review"),
      ),
    ])
    |> poll.with_duration(hours: 24)
    |> poll.with_allow_multiselect(False),
  )
}

kitazith/webhook/execute.validate and kitazith/webhook/edit.validate can be used before serialization to catch Discord payload constraint violations such as oversized embeds, empty required strings, missing attachment references, or duplicate attachment filenames.

Each error includes a reason for programmatic matching, and kitazith/validation.message can be used to render a human-readable message.

If you are sending query string params such as wait, thread_id, or with_components, use kitazith/webhook/execute_query, kitazith/webhook/edit_query, kitazith/webhook/delete_query, and kitazith/webhook/get_query. execute.validate_with_query also catches the Discord constraint that thread_id and thread_name must NOT be used together.

Non-Application-Owned Webhook Components

Discord only respects webhook components when with_components=true is set in the query string. For non-application-owned webhooks, Discord only allows non-interactive Components V2.

kitazith/component/* provides typed builders for:

When using these typed components:

import gleam/http
import gleam/http/request

import kitazith/color
import kitazith/component
import kitazith/component/container
import kitazith/component/media
import kitazith/component/section
import kitazith/component/separator
import kitazith/component/text_display
import kitazith/webhook/execute
import kitazith/webhook/execute_query

pub fn build_query() -> execute_query.ExecuteQuery {
  execute_query.new()
  |> execute_query.with_components(True)
}

pub fn build_payload() -> execute.ExecutePayload {
  let hero = section.new_thumbnail(media.new("https://example.com/release.webp"))

  execute.new()
  |> execute.with_components([
    component.text_display(text_display.new("# Release Notes")),
    component.section(section.new(
      components: [
        text_display.new("Version 7.3 is now live."),
        text_display.new("Maintenance completed without downtime."),
      ],
      accessory: hero,
    )),
    component.separator(separator.new()),
    component.container(
      container.new([
        container.text_display(text_display.new("Thanks for following the rollout.")),
      ])
      |> container.with_accent_color(
        color.from_rgb(red: 88, green: 101, blue: 242),
      ),
    ),
  ])
  |> execute.with_flags([execute.IsComponentsV2])
}

pub fn build_request() {
  let query = build_query()
  let payload = build_payload()
  let assert Ok(req) = request.to("YOUR_WEBHOOK_URL_HERE")

  req
  |> request.set_method(http.Post)
  |> request.set_query(execute_query.to_query(query))
  |> request.set_body(execute.to_string(payload))
}

component.raw remains available as an escape hatch for unsupported or application-owned webhook component payloads.

Using Attachments Within Embeds

Discord embeds can reference files uploaded in the same payload with the attachment://filename syntax.

import kitazith/attachment
import kitazith/embed
import kitazith/webhook/execute

pub fn build_payload_with_thumbnail() -> execute.ExecutePayload {
  let thumbnail = attachment.new(id: 0, filename: "thumb.png")

  execute.new()
  |> execute.with_attachments([thumbnail])
  |> execute.with_embeds([
    embed.new()
    |> embed.with_thumbnail(
      embed.EmbedThumbnail(url: attachment.to_embed_url(thumbnail)),
    ),
  ])
}

Decoding Execute Webhook Responses

When execute webhook is called with wait=true, Discord returns a message object.

import gleam/http/request
import kitazith/webhook/execute_query


pub fn main() {
  let query =
    execute_query.new()
    |> execute_query.with_wait(True)

  let assert Ok(base_req) = request.to("YOUR_WEBHOOK_URL_HERE")

  let req =
    base_req
    |> request.set_query(query |> execute_query.to_query)
}

kitazith/webhook/message decodes the minimal subset needed to read the created message IDs, timestamps, supported flags, and response attachment metadata.

Response attachments are exposed as message.MessageAttachment, which is separate from the request-side kitazith/attachment.Attachment type used by webhook/execute and webhook/edit.

import gleam/option
import kitazith/flag
import kitazith/snowflake
import kitazith/timestamp
import kitazith/webhook/message

pub fn parse_response() -> Nil {
  let body =
    "{\"id\":\"123\",\"channel_id\":\"456\",\"timestamp\":\"2026-03-15T09:30:00Z\",\"edited_timestamp\":null,\"webhook_id\":\"789\",\"flags\":4,\"attachments\":[{\"id\":\"987\",\"filename\":\"thumb.png\",\"size\":512,\"url\":\"https://cdn.discordapp.com/attachments/thumb.png\",\"proxy_url\":\"https://media.discordapp.net/attachments/thumb.png\"}]}"

  let assert Ok(decoded) = message.decode(body)
  let assert Ok(created_at) = timestamp.from_rfc3339("2026-03-15T09:30:00Z")

  assert decoded.id == snowflake.new("123")
  assert decoded.channel_id == snowflake.new("456")
  assert decoded.timestamp == created_at
  assert decoded.edited_timestamp == option.None
  assert decoded.webhook_id == option.Some(snowflake.new("789"))
  assert decoded.flags == option.Some([flag.SuppressEmbeds])
  assert decoded.attachments
    == [
      message.MessageAttachment(
        id: snowflake.new("987"),
        filename: "thumb.png",
        title: option.None,
        description: option.None,
        content_type: option.None,
        size: 512,
        url: "https://cdn.discordapp.com/attachments/thumb.png",
        proxy_url: "https://media.discordapp.net/attachments/thumb.png",
        height: option.None,
        width: option.None,
        ephemeral: option.None,
        duration_secs: option.None,
        waveform: option.None,
        flags: option.None,
      ),
    ]
}

Building Get Webhook Message Queries

Get Webhook Message does not use a request body. If the target message is in a thread, include thread_id in the query string and decode the response with kitazith/webhook/message.

import gleam/http/request
import gleam/httpc
import gleam/result

import kitazith/snowflake
import kitazith/webhook/get_query
import kitazith/webhook/message

pub fn get_message(webhook_url: String, message_id: String) {
  let query =
    get_query.new()
    |> get_query.with_thread_id(snowflake.new("1234567890"))

  let assert Ok(base_req) =
    request.to(webhook_url <> "/messages/" <> message_id)

  let req =
    base_req
    |> request.set_query(query |> get_query.to_query)

  use resp <- result.try(httpc.send(req))
  assert resp.status == 200

  let assert Ok(webhook_message) = message.decode(resp.body)
  echo webhook_message.id
  Ok(webhook_message)
}

Building Delete Webhook Message Queries

Delete Webhook Message does not use a request body. If the target message is in a thread, include thread_id in the query string.

import gleam/http
import gleam/http/request
import gleam/result

import kitazith/snowflake
import kitazith/webhook/delete_query

pub fn delete_message(webhook_url: String, message_id: String) {
  let query =
    delete_query.new()
    |> delete_query.with_thread_id(snowflake.new("1234567890"))

  let assert Ok(base_req) =
    request.to(webhook_url <> "/messages/" <> message_id)

  let req =
    base_req
    |> request.set_method(http.Delete)
    |> request.set_query(query |> delete_query.to_query)

  use resp <- result.try(httpc.send(req))
  echo resp.body
  // Discord returns 204 No Content on success.
  assert resp.status == 204
  Ok(resp)
}

Discord Message Formatting

kitazith/timestamp is for embed JSON timestamps.

Message content formatting helpers live under kitazith/message_formatting.

import kitazith/message_formatting/emoji
import kitazith/message_formatting/guild_navigation
import kitazith/message_formatting/mention
import kitazith/message_formatting/timestamp as message_timestamp
import kitazith/snowflake

pub fn user_tag() -> String {
  mention.user(snowflake.new("1234567890"))
}

pub fn eta() -> String {
  message_timestamp.format(
    seconds: 1_773_654_660,
    style: message_timestamp.RelativeTime,
  )
}

pub fn party() -> String {
  emoji.animated(name: "blobdance", id: snowflake.new("1234567890"))
}

pub fn server_guide() -> String {
  guild_navigation.format(guild_navigation.Guide)
}

Development

For runnable webhook flows, use examples/kitazith_example.

gleam check  # Type-check the library
gleam test   # Run the tests
gleam format # Format the source