Перейти к содержимому

Discord webhook на Java и Kotlin: HttpClient, OkHttp, Spring Boot

Как отправить Discord webhook из Java 11+ через HttpClient и из Kotlin через OkHttp + kotlinx.serialization: текст, embed, файлы multipart, 429, Spring Boot.

6 мин чтения Доступно также на English
На этой странице

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.

Теги: discord webhookвебхук discordjavakotlinhttpclientokhttpspring bootdiscord webhook java

Похожие статьи

Все статьи

Соберите это в визуальном редакторе

Embed, Components V2, кнопки и опросы с живым предпросмотром Discord. Бесплатно, без регистрации.