kitazith
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 kitazithIf you also want to send webhook requests as shown in the example below, add an HTTP client as well:
gleam add gleam_http gleam_httpcExample
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:
Text DisplaySectionwith aThumbnailaccessoryMedia GalleryFileSeparatorContainer
When using these typed components:
-
set
execute_query.with_components(True)oredit_query.with_components(True) -
include the
execute.IsComponentsV2oredit.IsComponentsV2flag -
do not send
content,embeds, orpollin the same payload
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