Discord webhook на Java и Kotlin: HttpClient, OkHttp, Spring Boot
Как отправить Discord webhook из Java 11+ через HttpClient и из Kotlin через OkHttp + kotlinx.serialization: текст, embed, файлы multipart, 429, Spring Boot.
На этой странице
- Что понадобится
- Java 11+: текстовое сообщение через HttpClient
- Java: embed через Jackson
- Получаем id сообщения через ?wait=true
- Java: загрузка файла через multipart/form-data
- Java: обработка 429 и переиспользуемый WebhookClient
- Kotlin: OkHttp + kotlinx.serialization
- Data-классы для payload
- Клиент: текст, embed и загрузка файла
- Повтор при 429 через interceptor
- Spring Boot: сервис DiscordNotifier
- Частые ошибки
- FAQ
- Нужна ли библиотека для Discord, чтобы слать вебхуки из Java или Kotlin?
- Как отредактировать или удалить сообщение, отправленное из Java?
- Можно ли вызывать вебхук прямо из Android-приложения через OkHttp?
- Почему Discord возвращает 204 и ничего больше?
- Итог
Discord webhook — это один HTTPS-запрос POST с JSON в теле, и начиная с Java 11 для него хватает самого JDK: java.net.http.HttpClient отправляет запрос, а Jackson (или любая JSON-библиотека, которая у вас уже есть) собирает payload. В Kotlin связка OkHttp + kotlinx.serialization даёт типизированные data-классы и нормальный multipart для файлов. В этой статье оба стека проходят одни и те же четыре задачи — текст, embed, загрузка файла и обработка 429, — а в конце всё складывается в переиспользуемый DiscordWebhookClient и сервис для Spring Boot.
Что понадобится
- Java 11 или новее. В примерах используются
HttpClient,Map.ofиvar; для нового кода разумно брать JDK 17 или 21. Проверьте версию:java -version. - URL вебхука. Настройки сервера → Интеграции → Вебхуки → Новый вебхук → Копировать URL вебхука. Выглядит так:
https://discord.com/api/webhooks/{id}/{token}. Токен — секрет: любой, у кого есть URL, может писать в канал. Держите его в переменной окружения, а не в исходниках. Утёк — удалите вебхук и создайте новый. Если вебхука ещё нет, начните с инструкции по получению URL. - JSON-библиотека для Java. В JDK её нет. Java-примеры используют Jackson (
com.fasterxml.jackson.core:jackson-databind); Gson работает точно так же. Kotlin-примеры — на kotlinx.serialization.
Экспортируйте URL один раз на сессию терминала:
export DISCORD_WEBHOOK_URL="https://discord.com/api/webhooks/YOUR_ID/YOUR_TOKEN"
Java 11+: текстовое сообщение через HttpClient
Без зависимостей, один файл, запускается командой java SendText.java:
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public class SendText {
public static void main(String[] args) throws Exception {
String url = System.getenv("DISCORD_WEBHOOK_URL");
String json = "{\"content\": \"Сборка #142 прошла ✅ в `main`\"}";
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();
HttpResponse<String> response =
client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.statusCode()); // 204 = доставлено
System.out.println(response.body()); // пусто при успехе, JSON с ошибкой иначе
}
}
Если сообщение ушло, Discord отвечает 204 No Content. Во всех остальных случаях в теле приходит JSON с описанием проблемы — поэтому пример его печатает. Две вещи, которые стоит знать про HttpClient: URI.create бросает IllegalArgumentException на лишний перевод строки или пробел, так что всё, что прочитано из файла, нужно обрезать через trim(); и один экземпляр HttpClient должен жить всё время работы приложения — у него внутри пул соединений и пул потоков.
Собирать JSON руками нормально для одной строки и мучительно для всего остального. Дальше Java-примеры сериализуют Map через Jackson:
// build.gradle.kts
dependencies {
implementation("com.fasterxml.jackson.core:jackson-databind:2.19.0")
}
Java: embed через Jackson
Embed — это вложенный объект, и Map.of вместе с List.of описывают его без единого POJO:
import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.Instant;
import java.util.List;
import java.util.Map;
ObjectMapper mapper = new ObjectMapper();
Map<String, Object> embed = Map.of(
"title", "Деплой завершён",
"description", "**api-gateway** v2.4.1 выкачен на production",
"color", 5763719, // десятичное число, не "#57F287"
"fields", List.of(
Map.of("name", "Длительность", "value", "3 мин 12 с", "inline", true),
Map.of("name", "Коммит", "value", "`a1b2c3d`", "inline", true)
),
"footer", Map.of("text", "deploy-bot"),
"timestamp", Instant.now().toString() // ISO 8601, у каждого читателя покажется в его часовом поясе
);
Map<String, Object> payload = Map.of(
"username", "Deploy Bot",
"embeds", List.of(embed)
);
String json = mapper.writeValueAsString(payload);
// дальше POST точно так же, как выше
Лимиты, которые проверяет API: content — до 2000 символов, description у embed — 4096, title — 256, до 25 полей в embed, до 10 embed в сообщении и 6000 символов суммарно на все embed одного сообщения. color — десятичное целое (5793266 — это дискордовский #5865F2). Map.of не принимает null-значения, поэтому ненужный ключ просто не добавляйте. Полная таблица — в гайде по лимитам embed.
Если хочется увидеть embed до того, как писать его на Java, визуальный конструктор на discord-webhook.com рендерит его вживую и экспортирует JSON; вложенные Map.of ложатся на этот вывод один в один.
Получаем id сообщения через ?wait=true
По умолчанию вы получаете 204 и больше ничего. Добавьте ?wait=true — и Discord вернёт 200 с созданным сообщением, включая его id, который нужен для последующего редактирования или удаления:
HttpRequest request = HttpRequest.newBuilder(URI.create(url + "?wait=true"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
String messageId = mapper.readTree(response.body()).get("id").asText();
// Редактирование позже: PATCH {url}/messages/{messageId} с JSON той же формы
String updatedJson = mapper.writeValueAsString(Map.of("content", "Сборка #142 прошла, артефакты загружены"));
HttpRequest edit = HttpRequest.newBuilder(URI.create(url + "/messages/" + messageId))
.header("Content-Type", "application/json")
.method("PATCH", HttpRequest.BodyPublishers.ofString(updatedJson))
.build();
У HttpRequest.Builder нет метода PATCH(), отсюда .method("PATCH", ...). Остальное — в гайде по редактированию и удалению.
Java: загрузка файла через multipart/form-data
В HttpClient нет помощника для multipart, тело собирается вручную. Формат строгий: каждая часть начинается с --boundary, заголовки отделены от содержимого пустой строкой, каждая строка заканчивается \r\n, тело закрывается --boundary--. Discord ждёт JSON в части с именем payload_json, а файлы — в files[0], files[1] и так далее:
Path file = Path.of("build/reports/tests.txt");
String boundary = "----DiscordWebhook" + UUID.randomUUID();
String payloadJson = mapper.writeValueAsString(Map.of("content", "Отчёт о тестах во вложении"));
ByteArrayOutputStream body = new ByteArrayOutputStream();
body.write(("--" + boundary + "\r\n"
+ "Content-Disposition: form-data; name=\"payload_json\"\r\n"
+ "Content-Type: application/json\r\n\r\n"
+ payloadJson + "\r\n").getBytes(StandardCharsets.UTF_8));
body.write(("--" + boundary + "\r\n"
+ "Content-Disposition: form-data; name=\"files[0]\"; filename=\"" + file.getFileName() + "\"\r\n"
+ "Content-Type: application/octet-stream\r\n\r\n").getBytes(StandardCharsets.UTF_8));
body.write(Files.readAllBytes(file));
body.write(("\r\n--" + boundary + "--\r\n").getBytes(StandardCharsets.UTF_8));
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
.header("Content-Type", "multipart/form-data; boundary=" + boundary)
.POST(HttpRequest.BodyPublishers.ofByteArray(body.toByteArray()))
.build();
До 10 файлов на сообщение, по умолчанию до 10 МБ каждый (на серверах с бустом больше). Чтобы показать загруженную картинку внутри embed, укажите в image.url этого embed внутри payload_json ссылку вида attachment://tests.png. Другие сценарии — в статье про отправку файлов через вебхук.
Java: обработка 429 и переиспользуемый WebhookClient
Discord ограничивает частоту запросов на каждый вебхук. При превышении приходит 429 Too Many Requests с полем retry_after (секунды, дробное) в JSON-теле и заголовками X-RateLimit-*. HttpClient сам 429 не повторяет, поэтому это делает класс ниже: читает retry_after, ждёт и пробует снова, максимум три раза. Любой другой 4xx/5xx превращается в IOException с телом ошибки от Discord — именно оно и нужно в логах.
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpRequest.BodyPublishers;
import java.net.http.HttpResponse;
import java.net.http.HttpResponse.BodyHandlers;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.Map;
import java.util.UUID;
public final class DiscordWebhookClient {
private static final int MAX_ATTEMPTS = 3;
private final HttpClient http = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
private final ObjectMapper mapper = new ObjectMapper();
private final String url;
public DiscordWebhookClient(String url) {
this.url = url.trim();
}
public void sendText(String content) throws IOException, InterruptedException {
send(Map.of("content", content));
}
/** payload: всё, что умеет сериализовать Jackson — Map, record, POJO. */
public void send(Object payload) throws IOException, InterruptedException {
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
.timeout(Duration.ofSeconds(15))
.header("Content-Type", "application/json")
.POST(BodyPublishers.ofString(mapper.writeValueAsString(payload)))
.build();
execute(request);
}
public void sendFile(Object payload, Path file) throws IOException, InterruptedException {
String boundary = "----DiscordWebhook" + UUID.randomUUID();
ByteArrayOutputStream body = new ByteArrayOutputStream();
write(body, "--" + boundary + "\r\n"
+ "Content-Disposition: form-data; name=\"payload_json\"\r\n"
+ "Content-Type: application/json\r\n\r\n");
body.write(mapper.writeValueAsBytes(payload));
write(body, "\r\n--" + boundary + "\r\n"
+ "Content-Disposition: form-data; name=\"files[0]\"; filename=\"" + file.getFileName() + "\"\r\n"
+ "Content-Type: application/octet-stream\r\n\r\n");
body.write(Files.readAllBytes(file));
write(body, "\r\n--" + boundary + "--\r\n");
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
.timeout(Duration.ofSeconds(30))
.header("Content-Type", "multipart/form-data; boundary=" + boundary)
.POST(BodyPublishers.ofByteArray(body.toByteArray()))
.build();
execute(request);
}
private void execute(HttpRequest request) throws IOException, InterruptedException {
for (int attempt = 1; ; attempt++) {
HttpResponse<String> response = http.send(request, BodyHandlers.ofString());
int status = response.statusCode();
if (status == 204 || status == 200) {
return;
}
if (status == 429 && attempt < MAX_ATTEMPTS) {
Thread.sleep((long) Math.ceil(retryAfterSeconds(response.body()) * 1000));
continue;
}
throw new IOException("Discord вернул HTTP " + status + ": " + response.body());
}
}
private double retryAfterSeconds(String body) {
try {
return mapper.readTree(body).path("retry_after").asDouble(1.0);
} catch (IOException e) {
return 1.0; // тело не JSON — всё равно подождём
}
}
private static void write(ByteArrayOutputStream out, String s) throws IOException {
out.write(s.getBytes(StandardCharsets.UTF_8));
}
}
Использование:
var discord = new DiscordWebhookClient(System.getenv("DISCORD_WEBHOOK_URL"));
discord.sendText("Ночной бэкап завершён: 4,2 ГБ за 3 мин 12 с");
discord.sendFile(Map.of("content", "Полный лог во вложении"), Path.of("backup.log"));
Практические цифры для этого цикла: один вебхук выдерживает порядка 30 запросов в минуту, а всплески больше примерно 5 запросов за 2 секунды режутся. Это наблюдаемые значения, а не официальная гарантия, поэтому всегда верьте retry_after, а не собственной константе. Заголовки и стратегии очередей разобраны в статье про rate limits.
Kotlin: OkHttp + kotlinx.serialization
В Kotlin payload становится data-классами, multipart берёт на себя OkHttp, а 429 для всех запросов прозрачно обрабатывает interceptor. Настройка Gradle:
plugins {
kotlin("jvm") version "2.2.0"
kotlin("plugin.serialization") version "2.2.0"
}
dependencies {
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
}
Если у вас уже Gson, замените json.encodeToString(message) на Gson().toJson(message), а @Serializable / @SerialName("avatar_url") — на гсоновский @SerializedName("avatar_url") (иначе Gson возьмёт имя свойства, а avatarUrl Discord проигнорирует); часть с OkHttp не меняется.
Data-классы для payload
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class EmbedField(val name: String, val value: String, val inline: Boolean = false)
@Serializable
data class EmbedFooter(val text: String)
@Serializable
data class Embed(
val title: String? = null,
val description: String? = null,
val color: Int? = null,
val fields: List<EmbedField> = emptyList(),
val footer: EmbedFooter? = null,
val timestamp: String? = null,
)
@Serializable
data class WebhookMessage(
val content: String? = null,
val username: String? = null,
@SerialName("avatar_url") val avatarUrl: String? = null,
val embeds: List<Embed> = emptyList(),
)
У каждого необязательного поля значение по умолчанию — null или пустой список. При encodeDefaults = false (это и есть умолчание Json) такие поля выбрасываются из вывода, и WebhookMessage(content = "привет") сериализуется ровно в {"content":"привет"}, а не в простыню из null.
Клиент: текст, embed и загрузка файла
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.File
import java.io.IOException
class DiscordWebhookClient(private val url: String) {
private val json = Json { encodeDefaults = false }
private val http = OkHttpClient.Builder()
.addInterceptor(RetryAfterInterceptor())
.build()
fun send(message: WebhookMessage) {
val body = json.encodeToString(message).toRequestBody(JSON)
execute(Request.Builder().url(url).post(body).build())
}
fun sendFile(message: WebhookMessage, file: File) {
val body = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("payload_json", json.encodeToString(message))
.addFormDataPart("files[0]", file.name, file.asRequestBody(OCTET_STREAM))
.build()
execute(Request.Builder().url(url).post(body).build())
}
private fun execute(request: Request) {
http.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
throw IOException("Discord вернул HTTP ${response.code}: ${response.body?.string()}")
}
}
}
private companion object {
val JSON = "application/json".toMediaType()
val OCTET_STREAM = "application/octet-stream".toMediaType()
}
}
Использование:
fun main() {
val discord = DiscordWebhookClient(System.getenv("DISCORD_WEBHOOK_URL"))
discord.send(WebhookMessage(content = "Релиз **v2.4.1** опубликован 🚀"))
discord.send(
WebhookMessage(
username = "Deploy Bot",
embeds = listOf(
Embed(
title = "Деплой завершён",
description = "**api-gateway** v2.4.1 выкачен на production",
color = 5763719,
fields = listOf(
EmbedField("Длительность", "3 мин 12 с", inline = true),
EmbedField("Коммит", "`a1b2c3d`", inline = true),
),
footer = EmbedFooter("deploy-bot"),
timestamp = java.time.Instant.now().toString(),
)
)
)
)
discord.sendFile(WebhookMessage(content = "Отчёт о тестах во вложении"), File("build/reports/tests.txt"))
}
MultipartBody.Builder сам пишет границы и переводы строк \r\n — именно поэтому загрузка файла в Kotlin занимает четыре строки против пятнадцати в Java. Блок .use { } закрывает тело ответа; забудете — OkHttp напишет в лог предупреждение об утёкшем соединении.
Повтор при 429 через interceptor
Application interceptor видит каждый ответ раньше вашего кода, поэтому обработка 429 живёт в одном месте:
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.Interceptor
import okhttp3.Response
class RetryAfterInterceptor(private val maxRetries: Int = 3) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
var response = chain.proceed(chain.request())
var retries = 0
while (response.code == 429 && retries < maxRetries) {
val retryAfter = response.body?.string()
?.let { runCatching { Json.parseToJsonElement(it).jsonObject["retry_after"]?.jsonPrimitive?.doubleOrNull }.getOrNull() }
?: 1.0
response.close()
Thread.sleep((retryAfter * 1000).toLong())
retries++
response = chain.proceed(chain.request())
}
return response
}
}
Thread.sleep уместен в CLI или фоновом воркере. В сервисе на корутинах вызывайте клиент внутри withContext(Dispatchers.IO) или перенесите повтор в suspend-функцию с delay().
Spring Boot: сервис DiscordNotifier
Spring Boot 3.2+ со spring-boot-starter-web автоматически конфигурирует RestClient.Builder, а Jackson уже в classpath, так что вся интеграция — один бин и одно свойство.
# application.yml
discord:
webhook-url: ${DISCORD_WEBHOOK_URL}
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
import java.util.List;
import java.util.Map;
@Service
public class DiscordNotifier {
private final RestClient restClient;
private final String webhookUrl;
public DiscordNotifier(RestClient.Builder builder,
@Value("${discord.webhook-url}") String webhookUrl) {
this.restClient = builder.build();
this.webhookUrl = webhookUrl;
}
public void text(String content) {
post(Map.of("content", content));
}
public void alert(String title, String description) {
post(Map.of("embeds", List.of(
Map.of("title", title, "description", description, "color", 15548997))));
}
private void post(Object payload) {
restClient.post()
.uri(webhookUrl)
.contentType(MediaType.APPLICATION_JSON)
.body(payload)
.retrieve()
.toBodilessEntity();
}
}
Внедряйте куда угодно: в @Scheduled-проверку здоровья, в @EventListener на ApplicationReadyEvent, в @ControllerAdvice, который репортит необработанные исключения. retrieve() бросает HttpClientErrorException на 4xx, а на 429 — конкретно HttpClientErrorException.TooManyRequests: ловите его и ставьте сообщение обратно в очередь, а не усыпляйте поток запроса. Пометьте метод @Async (плюс @EnableAsync на конфигурации), если медленный вызов Discord не должен блокировать ваш собственный HTTP-запрос. На Spring Boot 3.1 и старше ту же работу делает RestTemplate.postForEntity(webhookUrl, payload, Void.class).
Частые ошибки
400 Bad Requestс именем поля embed (например,embeds.0.description): превышен лимит длины. Обрезайте перед отправкой; все ограничения — в гайде по лимитам embed.400с упоминаниемcolor: вы отправили"#5865F2"строкой.color— десятичное целое; переводитInteger.parseInt("5865F2", 16).{"message":"Unknown Webhook","code":10015}с 404: URL неверный либо вебхук удалили в Discord. Скопируйте его заново из Интеграции → Вебхуки.IllegalArgumentException: Illegal character in pathизURI.create: в URL затесался перевод строки или пробел — обычно из файла или.envс переносом в конце. Спасает.trim().429 Too Many Requests: снизьте темп и соблюдайтеretry_after. Оба клиента выше это делают; голыйHttpClientилиOkHttpClient— нет.- Файл приходит, а
contentнет: часть с JSON должна называтьсяpayload_json, и каждая строка multipart обязана заканчиваться\r\n. Простой\nломает парсер молча. SerializationException: Serializer for class 'WebhookMessage' is not found: не подключён Gradle-плагинkotlin("plugin.serialization"), и@Serializableничего не сделал.- Отклонён
username: он не может содержатьclydeилиdiscord(без учёта регистра) и ограничен 80 символами. - Сообщение длиннее 2000 символов: лимит
content— 2000. Разбейте текст или перенесите его в description у embed, где доступно 4096.
Каждый статус и тело ответа подробно разобраны в справочнике по ошибкам вебхуков.
FAQ
Нужна ли библиотека для Discord, чтобы слать вебхуки из Java или Kotlin?
Нет. Вебхук — один HTTPS-запрос POST, авторизованный самим URL. java.net.http.HttpClient или OkHttp плюс JSON-сериализатор закрывают текст, embed, файлы, редактирование и удаление. Библиотеки для ботов тащат gateway-соединение и токен бота, которые здесь не нужны.
Как отредактировать или удалить сообщение, отправленное из Java?
Отправьте с ?wait=true, возьмите id из ответа 200, затем выполните PATCH или DELETE на https://discord.com/api/webhooks/{id}/{token}/messages/{message_id}. В HttpClient используйте .method("PATCH", body) — отдельного PATCH() там нет.
Можно ли вызывать вебхук прямо из Android-приложения через OkHttp?
Код заработает, но URL вебхука — секрет, а любой APK декомпилируется. Держите URL на своём бэкенде и пусть приложение обращается к нему; бэкенд уже использует клиент из этой статьи.
Почему Discord возвращает 204 и ничего больше?
Потому что вызов по умолчанию работает по принципу «отправил и забыл». Добавьте к URL ?wait=true, и придёт 200 с JSON созданного сообщения, включая его id.
Итог
HttpClient из Java 11 закрывает текст, embed и даже multipart, если написать строки с границами самому; Kotlin с OkHttp и kotlinx.serialization делает тот же код короче и типизированным; Spring Boot сводит всё к вызову RestClient и одному свойству. Какой бы стек вы ни выбрали — держите один экземпляр клиента, URL в переменной окружения и соблюдайте retry_after.
Чтобы собрать embed до того, как кодировать его, бесплатный конструктор Discord Webhook показывает сообщение вживую и экспортирует JSON, который остаётся переложить в Map.of или data-классы.
Смежные статьи: Discord webhook на Go, Discord webhook на C# и справочник по лимитам embed.