Skip to main content

Discord Webhook Java & Kotlin Guide: HttpClient, OkHttp, Spring Boot

Discord webhook Java guide: HttpClient in Java 11+, OkHttp + kotlinx.serialization in Kotlin, embeds, multipart file upload, 429 retries, Spring Boot.

7 min read Also available in Русский
On this page

A Discord webhook is one HTTPS POST with a JSON body, and the JDK has had everything for that since Java 11: java.net.http.HttpClient sends the request, Jackson (or whichever JSON library you already have) builds the payload. On the Kotlin side, OkHttp plus kotlinx.serialization gives you typed data classes and a real multipart API for files. This guide takes both stacks through the same four jobs, plain text, embeds, file upload and 429 handling, then packs the result into a reusable DiscordWebhookClient and a Spring Boot service.

What you need

  • Java 11 or newer. The examples use HttpClient, Map.of and var; JDK 17 or 21 is the sensible choice for anything new. Check with java -version.
  • A webhook URL. Server Settings → Integrations → Webhooks → New Webhook → Copy Webhook URL. It looks like https://discord.com/api/webhooks/{id}/{token}. The token is a secret: anyone who has the URL can post to the channel, so keep it in an environment variable, never in source. If it leaks, delete the webhook and create a new one. Start with how to get a webhook URL if you have never made one.
  • A JSON library for Java. The JDK ships none. The Java examples use Jackson (com.fasterxml.jackson.core:jackson-databind); Gson works the same way. The Kotlin examples use kotlinx.serialization.

Export the URL once per shell:

export DISCORD_WEBHOOK_URL="https://discord.com/api/webhooks/YOUR_ID/YOUR_TOKEN"

Java 11+: send a text message with HttpClient

No dependencies, one file, runnable with 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\": \"Build #142 passed ✅ on `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 = delivered
        System.out.println(response.body());       // empty on success, JSON error otherwise
    }
}

Discord answers 204 No Content when the message is posted. Anything else comes with a JSON body that names the problem, which is why the example prints it. Two things to know about HttpClient: URI.create throws IllegalArgumentException on a stray newline or space, so trim anything you read from a file, and one HttpClient instance should live for the whole application, because it owns a connection pool and a thread pool.

Hand-written JSON strings are fine for a one-liner and painful for anything else. From here on the Java examples serialize a Map with Jackson:

// build.gradle.kts
dependencies {
    implementation("com.fasterxml.jackson.core:jackson-databind:2.19.0")
}

Java: embeds with Jackson

An embed is a nested object, and Map.of plus List.of express it without a single 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", "Deploy finished",
        "description", "**api-gateway** v2.4.1 is live on production",
        "color", 5763719,                      // decimal integer, not "#57F287"
        "fields", List.of(
                Map.of("name", "Duration", "value", "3m 12s", "inline", true),
                Map.of("name", "Commit", "value", "`a1b2c3d`", "inline", true)
        ),
        "footer", Map.of("text", "deploy-bot"),
        "timestamp", Instant.now().toString()  // ISO 8601, rendered in each reader's local time
);

Map<String, Object> payload = Map.of(
        "username", "Deploy Bot",
        "embeds", List.of(embed)
);

String json = mapper.writeValueAsString(payload);
// then POST it exactly as above

Limits the API enforces: content tops out at 2000 characters, an embed description at 4096, a title at 256, 25 fields per embed, 10 embeds per message and 6000 characters across all embeds of one message. color is a decimal integer (5793266 is Discord’s #5865F2). Map.of rejects null values, so leave a key out instead of setting it to null. The full table is in the embed limits guide.

If you want to see the embed before writing it in Java, the visual builder on discord-webhook.com renders it live and exports the JSON; nested Map.of calls map onto that output one to one.

Get the message id back with ?wait=true

By default you get 204 and nothing else. Append ?wait=true and Discord returns 200 with the created message, including its id, which you need to edit or delete the message later:

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();

// Edit later: PATCH {url}/messages/{messageId} with the same JSON shape
String updatedJson = mapper.writeValueAsString(Map.of("content", "Build #142 passed, artifacts uploaded"));
HttpRequest edit = HttpRequest.newBuilder(URI.create(url + "/messages/" + messageId))
        .header("Content-Type", "application/json")
        .method("PATCH", HttpRequest.BodyPublishers.ofString(updatedJson))
        .build();

HttpRequest.Builder has no PATCH() shortcut, hence .method("PATCH", ...). The edit and delete guide covers the rest.

Java: upload a file with multipart/form-data

HttpClient has no multipart helper, so you assemble the body yourself. The format is strict: each part starts with --boundary, headers are separated from content by a blank line, every line ends with \r\n, and the body closes with --boundary--. Discord expects the JSON in a part named payload_json and files in files[0], files[1] and so on:

Path file = Path.of("build/reports/tests.txt");
String boundary = "----DiscordWebhook" + UUID.randomUUID();
String payloadJson = mapper.writeValueAsString(Map.of("content", "Test report attached"));

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();

Up to 10 files per message, 10 MB each by default (boosted servers allow more). To show an uploaded image inside an embed, reference it as attachment://tests.png in the embed’s image.url inside payload_json. More patterns are in sending files through webhooks.

Java: handling 429 and a reusable WebhookClient

Discord rate-limits per webhook. Cross the line and you get 429 Too Many Requests with retry_after (seconds, fractional) in the JSON body plus X-RateLimit-* headers. HttpClient does not retry a 429 on its own, so the class below does: it reads retry_after, sleeps, and tries again up to three times. Any other 4xx/5xx becomes an IOException carrying Discord’s error body, which is the message you actually want in your logs.

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: anything Jackson can serialize — a Map, a record, a 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 returned 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; // body was not JSON; back off anyway
        }
    }

    private static void write(ByteArrayOutputStream out, String s) throws IOException {
        out.write(s.getBytes(StandardCharsets.UTF_8));
    }
}

Usage:

var discord = new DiscordWebhookClient(System.getenv("DISCORD_WEBHOOK_URL"));
discord.sendText("Nightly backup finished: 4.2 GB in 3m 12s");
discord.sendFile(Map.of("content", "Full log attached"), Path.of("backup.log"));

Practical numbers for that loop: a single webhook sustains about 30 requests per minute, and bursts beyond roughly 5 in 2 seconds get throttled. Those are observed values, not published guarantees, so always trust retry_after over any constant of your own. The rate limits article covers the headers and queueing strategies.

Kotlin: OkHttp + kotlinx.serialization

In Kotlin the payload becomes data classes, OkHttp handles multipart, and an interceptor handles 429 for every request transparently. Gradle setup:

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")
}

If you already use Gson, swap json.encodeToString(message) for Gson().toJson(message) and replace @Serializable / @SerialName("avatar_url") with Gson’s @SerializedName("avatar_url") (Gson uses the Kotlin property name otherwise, and Discord ignores avatarUrl); the OkHttp part stays identical.

Data classes for the 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(),
)

Every optional field defaults to null or an empty list. With encodeDefaults = false (the default for Json), those are dropped from the output, so WebhookMessage(content = "hi") serializes to exactly {"content":"hi"} rather than a wall of nulls.

The client: text, embeds and file upload

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 returned HTTP ${response.code}: ${response.body?.string()}")
            }
        }
    }

    private companion object {
        val JSON = "application/json".toMediaType()
        val OCTET_STREAM = "application/octet-stream".toMediaType()
    }
}

Usage:

fun main() {
    val discord = DiscordWebhookClient(System.getenv("DISCORD_WEBHOOK_URL"))

    discord.send(WebhookMessage(content = "Release **v2.4.1** is out 🚀"))

    discord.send(
        WebhookMessage(
            username = "Deploy Bot",
            embeds = listOf(
                Embed(
                    title = "Deploy finished",
                    description = "**api-gateway** v2.4.1 is live on production",
                    color = 5763719,
                    fields = listOf(
                        EmbedField("Duration", "3m 12s", inline = true),
                        EmbedField("Commit", "`a1b2c3d`", inline = true),
                    ),
                    footer = EmbedFooter("deploy-bot"),
                    timestamp = java.time.Instant.now().toString(),
                )
            )
        )
    )

    discord.sendFile(WebhookMessage(content = "Test report attached"), File("build/reports/tests.txt"))
}

MultipartBody.Builder writes the boundaries and \r\n line endings for you, which is why the Kotlin upload is four lines where the Java one is fifteen. The .use { } block closes the response body; forget it and OkHttp logs a leaked-connection warning.

Retry on 429 with an interceptor

An application interceptor sees every response before your code does, so 429 handling lives in one place:

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 is fine in a CLI or a background worker. In a coroutine-based service, run the call inside withContext(Dispatchers.IO) or move the retry into a suspend function that uses delay().

Spring Boot: a DiscordNotifier service

Spring Boot 3.2+ with spring-boot-starter-web auto-configures RestClient.Builder, and Jackson is already on the classpath, so the whole integration is one bean and one property.

# 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();
    }
}

Inject it anywhere: a @Scheduled health check, an @EventListener for ApplicationReadyEvent, a @ControllerAdvice that reports unhandled exceptions. retrieve() throws HttpClientErrorException for 4xx responses, HttpClientErrorException.TooManyRequests for 429 specifically, so catch that and re-queue rather than sleeping inside a request thread. Mark the method @Async (with @EnableAsync on a configuration class) if a slow Discord call must not block your own HTTP request. On Spring Boot 3.1 or older, RestTemplate.postForEntity(webhookUrl, payload, Void.class) does the same job.

Common errors

  • 400 Bad Request naming an embed field (for example embeds.0.description): a length limit was crossed. Truncate before sending; the embed limits guide lists every cap.
  • 400 mentioning color: you sent "#5865F2" as a string. color is a decimal integer; Integer.parseInt("5865F2", 16) converts it.
  • {"message":"Unknown Webhook","code":10015} with 404: the URL is wrong or the webhook was deleted in Discord. Re-copy it from Integrations → Webhooks.
  • IllegalArgumentException: Illegal character in path from URI.create: a newline or space in the URL, usually from a file or .env with a trailing line break. .trim() it.
  • 429 Too Many Requests: slow down and honour retry_after. Both clients above do; a bare HttpClient or OkHttpClient does not.
  • The file arrives but content is missing: the JSON part must be named payload_json and every multipart line must end with \r\n. A plain \n breaks the parser silently.
  • SerializationException: Serializer for class 'WebhookMessage' is not found: the kotlin("plugin.serialization") Gradle plugin is missing, so @Serializable did nothing.
  • username rejected: it may not contain clyde or discord (case-insensitive) and is capped at 80 characters.
  • Message longer than 2000 characters: content is capped at 2000. Split it, or move the text into an embed description, which allows 4096.

The webhook errors reference explains each status and body in more depth.

FAQ

Do I need a Discord library to send webhooks from Java or Kotlin?

No. A webhook is a single HTTPS POST authenticated by its URL. java.net.http.HttpClient or OkHttp plus a JSON serializer covers text, embeds, files, editing and deleting. Bot libraries add a gateway connection and a bot token you do not need for this.

How do I edit or delete a message I sent from Java?

Send with ?wait=true, read id from the 200 response, then PATCH or DELETE https://discord.com/api/webhooks/{id}/{token}/messages/{message_id}. With HttpClient, use .method("PATCH", body) because there is no PATCH() shortcut.

Can I call the webhook straight from an Android app with OkHttp?

The code runs, but the webhook URL is a secret and any APK can be decompiled. Put the URL on your own backend and have the app call that; the backend then uses the client from this article.

Why does Discord return 204 and nothing else?

Because the default execute call is fire-and-forget. Append ?wait=true to the URL to get 200 with the created message JSON, including its id.

Wrap-up

Java 11’s HttpClient covers text, embeds and even multipart if you write the boundary lines yourself; Kotlin with OkHttp and kotlinx.serialization makes the same code shorter and typed; Spring Boot reduces it to a RestClient call and a property. Whichever you pick, keep one client instance, keep the URL in an environment variable and honour retry_after.

To prototype the embed before coding it, the free Discord Webhook builder previews the message live and exports the JSON you then translate into Map.of or data classes.

Related guides: Discord webhooks in Go, Discord webhooks in C# and the embed limits reference.

Tags: javakotlindiscord webhookhttpclientokhttpkotlinx.serializationspring boottutorial

Related articles

All articles

Build it in the visual editor

Embeds, Components V2, buttons and polls with a live Discord preview. Free, no signup.